275 lines
9.1 KiB
Python
275 lines
9.1 KiB
Python
"""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)
|