"""Full 16x16 board built from one 8x8 quadrant rotated around the board centre. Quadrants are numbered clockwise from the top-left: 0 TL, 1 TR, 2 BR, 3 BL. Quadrant k is quadrant 0 rotated 90° clockwise k times. """ import tomllib from collections.abc import Iterator, Mapping from dataclasses import dataclass from enum import StrEnum from functools import cached_property from pathlib import Path from typing import NamedTuple from dojo.grid import Grid, parse_grid QUADRANT_SIZE = 8 BOARD_SIZE = 2 * QUADRANT_SIZE QUADRANTS = 4 QUADRANT_DOJO_CELLS = frozenset({(6, 6), (6, 7), (7, 6), (7, 7)}) TUNNEL_IDS = frozenset("123456789") class Square(StrEnum): SAFE = "S" DOJO = "D" TATAMI = "." ALLENAMENTO = "A" FATICA = "F" NEBBIA = "?" PASSO_FALSO = "P" SFIDA = "K" SQUARE_CODES = frozenset(Square) class Pos(NamedTuple): row: int col: int def __str__(self) -> str: return f"({self.row}, {self.col})" type Edge = tuple[Pos, Pos] def edge(a: Pos, b: Pos) -> Edge: return (a, b) if a < b else (b, a) def rotate(p: Pos, times: int = 1) -> Pos: for _ in range(times % QUADRANTS): p = Pos(p.col, BOARD_SIZE - 1 - p.row) return p def quadrant_of(p: Pos) -> int: top = p.row < QUADRANT_SIZE left = p.col < QUADRANT_SIZE if top: return 0 if left else 1 return 3 if left else 2 def is_tunnel(code: str) -> bool: return code in TUNNEL_IDS class BoardError(ValueError): def __init__(self, problems: list[str]) -> None: super().__init__("\n".join(problems)) self.problems = problems @dataclass(frozen=True, eq=False) class Board: cells: tuple[tuple[str, ...], ...] # Closed edges between orthogonally adjacent in-bounds cells. The outer border is # always closed and is not stored. walls: frozenset[Edge] tunnels: Mapping[Pos, Pos] @property def size(self) -> int: return len(self.cells) def code(self, p: Pos) -> str: return self.cells[p.row][p.col] def positions(self) -> Iterator[Pos]: for r in range(self.size): for c in range(self.size): yield Pos(r, c) def in_bounds(self, p: Pos) -> bool: return 0 <= p.row < self.size and 0 <= p.col < self.size def neighbours(self, p: Pos) -> Iterator[Pos]: for dr, dc in ((-1, 0), (0, 1), (1, 0), (0, -1)): q = Pos(p.row + dr, p.col + dc) if self.in_bounds(q) and edge(p, q) not in self.walls: yield q def cells_with(self, code: str) -> frozenset[Pos]: return frozenset(p for p in self.positions() if self.code(p) == code) @cached_property def dojo(self) -> frozenset[Pos]: return self.cells_with(Square.DOJO) def safe_room(self, quadrant: int) -> frozenset[Pos]: return frozenset(p for p in self.cells_with(Square.SAFE) if quadrant_of(p) == quadrant) def to_grid(self) -> Grid: n = self.size h_walls = tuple( tuple(r in (0, n) or edge(Pos(r - 1, c), Pos(r, c)) in self.walls for c in range(n)) for r in range(n + 1) ) v_walls = tuple( tuple(c in (0, n) or edge(Pos(r, c - 1), Pos(r, c)) in self.walls for c in range(n + 1)) for r in range(n) ) return Grid(cells=self.cells, h_walls=h_walls, v_walls=v_walls) def build_board(quadrant: Grid, tunnel_offsets: Mapping[str, int] | None = None) -> Board: """Rotate ``quadrant`` into a full board. Raises ``BoardError`` on structural problems. A tunnel id appears on exactly two quadrant cells, ``a`` and ``b`` in reading order. With offset ``o``, ``a`` in quadrant k is linked to ``b`` in quadrant k + o. """ offsets = dict(tunnel_offsets or {}) problems = _check_quadrant(quadrant, offsets) if problems: raise BoardError(problems) cells = [[""] * BOARD_SIZE for _ in range(BOARD_SIZE)] for r in range(QUADRANT_SIZE): for c in range(QUADRANT_SIZE): for k in range(QUADRANTS): p = rotate(Pos(r, c), k) cells[p.row][p.col] = quadrant.cells[r][c] walls = frozenset( edge(rotate(a, k), rotate(b, k)) for a, b in _quadrant_walls(quadrant) for k in range(QUADRANTS) ) tunnels: dict[Pos, Pos] = {} for tunnel_id, (a, b) in _tunnel_positions(quadrant).items(): offset = offsets.get(tunnel_id, 0) for k in range(QUADRANTS): a_here, b_there = rotate(a, k), rotate(b, k + offset) tunnels[a_here] = b_there tunnels[b_there] = a_here return Board( cells=tuple(tuple(row) for row in cells), walls=walls, tunnels=tunnels, ) def _check_quadrant(quadrant: Grid, offsets: Mapping[str, int]) -> list[str]: n = QUADRANT_SIZE if (quadrant.height, quadrant.width) != (n, n): return [f"quadrant must be {n}x{n}, got {quadrant.height}x{quadrant.width}"] problems: list[str] = [] for r in range(n): for c in range(n): code = quadrant.cells[r][c] if code not in SQUARE_CODES and not is_tunnel(code): problems.append(f"cell {Pos(r, c)}: unknown code '{code}'") if not all(quadrant.h_walls[0]): problems.append("top border of the quadrant must be fully walled (outer border)") if not all(row[0] for row in quadrant.v_walls): problems.append("left border of the quadrant must be fully walled (outer border)") # Rotation maps the bottom seam onto the right seam, so they describe the same edges. for i in range(n): right_seam, bottom_seam = quadrant.v_walls[i][n], quadrant.h_walls[n][i] if right_seam != bottom_seam: problems.append( f"seam mismatch: right border at row {i} is " f"{_wall_word(right_seam)} but bottom border at column {i} is " f"{_wall_word(bottom_seam)}; rotation requires them to be equal" ) dojo = {(r, c) for r in range(n) for c in range(n) if quadrant.cells[r][c] == Square.DOJO} if dojo != QUADRANT_DOJO_CELLS: expected = ", ".join(str(Pos(*p)) for p in sorted(QUADRANT_DOJO_CELLS)) problems.append(f"Dojo cells must be exactly {expected}") tunnel_positions = _tunnel_positions(quadrant) for tunnel_id, positions in sorted(tunnel_positions.items()): if len(positions) != 2: problems.append( f"tunnel '{tunnel_id}' must appear on exactly 2 cells, found {len(positions)}" ) for tunnel_id, offset in sorted(offsets.items()): if tunnel_id not in tunnel_positions: problems.append(f"legend links tunnel '{tunnel_id}', which is not on the quadrant") if isinstance(offset, bool) or not isinstance(offset, int) or not 0 <= offset < QUADRANTS: problems.append(f"tunnel '{tunnel_id}': quadrant_offset must be 0..3, got {offset!r}") return problems def _wall_word(is_wall: bool) -> str: return "walled" if is_wall else "open" def _quadrant_walls(quadrant: Grid) -> Iterator[Edge]: """Closed edges owned by the quadrant: its interior plus the right and bottom seams.""" n = QUADRANT_SIZE for r in range(n): for c in range(1, n + 1): if quadrant.v_walls[r][c]: yield edge(Pos(r, c - 1), Pos(r, c)) for r in range(1, n + 1): for c in range(n): if quadrant.h_walls[r][c]: yield edge(Pos(r - 1, c), Pos(r, c)) def _tunnel_positions(quadrant: Grid) -> dict[str, list[Pos]]: """Tunnel cells per id, in reading order.""" found: dict[str, list[Pos]] = {} for r in range(QUADRANT_SIZE): for c in range(QUADRANT_SIZE): code = quadrant.cells[r][c] if is_tunnel(code): found.setdefault(code, []).append(Pos(r, c)) return found def parse_legend(data: Mapping[str, object]) -> dict[str, int]: """Return tunnel quadrant offsets from a parsed ``legend.toml``.""" problems: list[str] = [] unknown_sections = set(data) - {"tunnels"} if unknown_sections: problems.append(f"legend: unknown sections {sorted(unknown_sections)}") tunnels = data.get("tunnels", {}) offsets: dict[str, int] = {} if not isinstance(tunnels, Mapping): problems.append("legend: [tunnels] must be a table") tunnels = {} for tunnel_id, entry in tunnels.items(): if tunnel_id not in TUNNEL_IDS: problems.append(f"legend: tunnel id '{tunnel_id}' must be a digit 1-9") continue if not isinstance(entry, Mapping) or set(entry) != {"quadrant_offset"}: problems.append(f"legend: [tunnels.{tunnel_id}] must contain only quadrant_offset") continue offsets[tunnel_id] = entry["quadrant_offset"] if problems: raise BoardError(problems) return offsets def load_board(quadrant_path: Path, legend_path: Path | None = None) -> Board: quadrant = parse_grid(quadrant_path.read_text(encoding="utf-8"), source=str(quadrant_path)) offsets: dict[str, int] = {} if legend_path is not None: with legend_path.open("rb") as handle: offsets = parse_legend(tomllib.load(handle)) return build_board(quadrant, offsets)