from __future__ import annotations from dataclasses import dataclass @dataclass(frozen=True) class Piece: """A polyomino piece defined by a set of (x, y) coordinates. Coordinates are relative to a reference point (typically the top-left corner of the bounding box after normalization). """ name: str squares: frozenset[tuple[int, int]] @property def size(self) -> int: return len(self.squares) def to_dict(self) -> dict: return { "name": self.name, "squares": sorted(self.squares), } @classmethod def from_dict(cls, data: dict) -> Piece: return cls( name=data["name"], squares=frozenset(tuple(s) for s in data["squares"]), ) @dataclass class OrientedPiece: """A piece in a specific orientation placed at a specific position.""" piece_id: int orientation_id: int squares: list[tuple[int, int]] corners: list[tuple[int, int]] @dataclass class Move: """A complete move: which piece, which orientation, where placed.""" piece_id: int orientation_id: int x: int y: int @dataclass class PieceOrientation: """A piece in a specific orientation (before placement on board).""" piece_id: int orientation_id: int squares: list[tuple[int, int]] # normalized relative coordinates corners: list[tuple[int, int]] # corner cells relative to squares def _normalize(squares: list[tuple[int, int]]) -> list[tuple[int, int]]: """Shift coordinates so the minimum x and y are 0.""" min_x = min(x for x, _ in squares) min_y = min(y for _, y in squares) return [(x - min_x, y - min_y) for x, y in squares] def _rotate(squares: list[tuple[int, int]]) -> list[tuple[int, int]]: """Rotate 90 degrees clockwise: (x, y) -> (y, -x).""" return [(y, -x) for x, y in squares] def _flip(squares: list[tuple[int, int]]) -> list[tuple[int, int]]: """Flip horizontally: (x, y) -> (-x, y).""" return [(-x, y) for x, y in squares] def _compute_corners(squares: list[tuple[int, int]]) -> list[tuple[int, int]]: """Compute the corner cells for a piece. Corner cells are the diagonal neighbors of each square that are not edge-adjacent to any other square in the piece. These are the cells where a same-color piece must touch (corner-to-corner). """ square_set = set(squares) corners = set() for x, y in squares: for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]: cx, cy = x + dx, y + dy if (cx, cy) not in square_set: # Check that this corner is not edge-adjacent to another square edge_neighbors = [ (cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1) ] if not any(en in square_set for en in edge_neighbors): corners.add((cx, cy)) return sorted(corners) def generate_orientations(piece: Piece) -> list[PieceOrientation]: """Generate all unique orientations of a piece. Applies 4 rotations and 2 flips, then deduplicates by comparing normalized coordinate sets. """ orientations: list[PieceOrientation] = [] seen: set[tuple[tuple[int, int], ...]] = set() squares = sorted(piece.squares) for flip_count in range(2): for rotation_count in range(4): current = list(squares) for _ in range(rotation_count): current = _rotate(current) for _ in range(flip_count): current = _flip(current) normalized = tuple(sorted(_normalize(current))) if normalized not in seen: seen.add(normalized) corners = _compute_corners(list(normalized)) orientations.append( PieceOrientation( piece_id=-1, # Set by PieceSet orientation_id=len(orientations), squares=list(normalized), corners=corners, ) ) return orientations # --------------------------------------------------------------------------- # Standard Blokus piece sets # --------------------------------------------------------------------------- # The 21 free polyominoes of size 1-5 # Naming follows standard pentomino/tetromino conventions _I1 = Piece("I1", frozenset([(0, 0)])) _I2 = Piece("I2", frozenset([(0, 0), (0, 1)])) _I3 = Piece("I3", frozenset([(0, 0), (0, 1), (0, 2)])) _V3 = Piece("V3", frozenset([(0, 0), (1, 0), (0, 1)])) _I4 = Piece("I4", frozenset([(0, 0), (0, 1), (0, 2), (0, 3)])) _L4 = Piece("L4", frozenset([(0, 0), (0, 1), (0, 2), (1, 0)])) _T4 = Piece("T4", frozenset([(0, 0), (1, 0), (2, 0), (1, 1)])) _S4 = Piece("S4", frozenset([(0, 0), (1, 0), (1, 1), (2, 1)])) _O4 = Piece("O4", frozenset([(0, 0), (1, 0), (0, 1), (1, 1)])) _I5 = Piece("I5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)])) _L5 = Piece("L5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 0)])) _Y5 = Piece("Y5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 1)])) _N5 = Piece("N5", frozenset([(0, 0), (1, 0), (2, 0), (2, 1), (3, 1)])) _T5 = Piece("T5", frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, 2)])) _U5 = Piece("U5", frozenset([(0, 0), (2, 0), (0, 1), (1, 1), (2, 1)])) _V5 = Piece("V5", frozenset([(0, 0), (0, 1), (0, 2), (1, 0), (2, 0)])) _W5 = Piece("W5", frozenset([(0, 0), (0, 1), (1, 0), (1, 1), (2, 0)])) _Z5 = Piece("Z5", frozenset([(0, 0), (1, 0), (1, 1), (1, 2), (2, 2)])) _F5 = Piece("F5", frozenset([(0, 0), (1, 0), (1, 1), (2, 1), (1, 2)])) _X5 = Piece("X5", frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, -1)])) _P5 = Piece("P5", frozenset([(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)])) STANDARD_PIECES: list[Piece] = [ _I1, _I2, _I3, _V3, _I4, _L4, _T4, _S4, _O4, _I5, _L5, _Y5, _N5, _T5, _U5, _V5, _W5, _Z5, _F5, _X5, _P5, ] # Blokus Duo uses the same pieces but on a 14x14 board DUO_PIECES: list[Piece] = STANDARD_PIECES # Blokus Junior: simplified pieces (only 12 unique pieces, 2 copies each) # Uses only pieces with size <= 4 for simplicity JUNIOR_PIECES: list[Piece] = [ _I1, _I2, _I3, _V3, _I4, _L4, _T4, _S4, _O4, ] class PieceSet: """A collection of pieces with pre-computed orientations. This class manages the piece set used in a Blokus game, including all unique orientations for each piece and a lookup table for generating moves. """ def __init__(self, pieces: list[Piece]): self.pieces: list[Piece] = pieces self.piece_names: list[str] = [p.name for p in pieces] self.piece_id_map: dict[str, int] = {name: i for i, name in enumerate(self.piece_names)} # Pre-compute all orientations for each piece self.orientations: list[list[PieceOrientation]] = [] for piece_id, piece in enumerate(pieces): orients = generate_orientations(piece) for orient in orients: orient.piece_id = piece_id self.orientations.append(orients) # Total number of (piece, orientation) combinations self.num_orientations: int = sum(len(o) for o in self.orientations) @property def num_pieces(self) -> int: return len(self.pieces) def get_orientations(self, piece_id: int) -> list[PieceOrientation]: return self.orientations[piece_id] def get_piece(self, piece_id: int) -> Piece: return self.pieces[piece_id] def get_piece_id(self, name: str) -> int: return self.piece_id_map[name] def to_dict(self) -> dict: return { "pieces": [p.to_dict() for p in self.pieces], } @classmethod def from_dict(cls, data: dict) -> PieceSet: pieces = [Piece.from_dict(p) for p in data["pieces"]] return cls(pieces) @classmethod def from_json(cls, path: str) -> PieceSet: import json with open(path) as f: data = json.load(f) return cls.from_dict(data) def save_json(self, path: str) -> None: import json with open(path, "w") as f: json.dump(self.to_dict(), f, indent=2) def __len__(self) -> int: return len(self.pieces) def __repr__(self) -> str: return f"PieceSet(num_pieces={self.num_pieces}, num_orientations={self.num_orientations})"