Initial commit, with rules 0.5 and basic bots

This commit is contained in:
2026-09-26 16:01:46 +02:00
commit 0aaf113fc3
32 changed files with 4206 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
"""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)