#!/usr/bin/env python3 # Combined Cython implementation of Blokus game engine # Compile with: python setup_cython.py build_ext --inplace from __future__ import annotations cimport cython from cython cimport bint import numpy as np cimport numpy as cnp # ------------------------------------------------------------------- # Board class (Cython-optimized) # ------------------------------------------------------------------- cdef class Board: """Cython-optimized Board class with static typing. Key optimizations: - Static typing eliminates Python interpreter overhead - Direct C-level array access instead of Python method calls - Precomputed bounds checking """ cdef public int size cdef public cnp.int8_t[:, :] grid cdef public dict _occupied_cache def __init__(self, int size): self.size = size self.grid = np.zeros((size, size), dtype=np.int8) self._occupied_cache = {} def place(self, int player_idx, list squares): """Place a player's piece on the board.""" cdef int x, y cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size for x, y in squares: if 0 <= x < size and 0 <= y < size: grid_view[y, x] = player_idx # Update occupied cache incrementally if player_idx in self._occupied_cache: self._occupied_cache[player_idx].update(squares) def clear(self) -> None: """Reset the board to empty.""" self.grid.fill(0) def is_empty(self, int x, int y) -> bool: """Check if a cell is empty (C-level fast).""" cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size if 0 <= x < size and 0 <= y < size: return grid_view[y, x] == 0 return False def in_bounds(self, int x, int y) -> bool: """Check if coordinates are within the board (C-level fast).""" cdef int size = self.size return 0 <= x < size and 0 <= y < size def has_overlap(self, list squares) -> bool: """Check if any square is already occupied (C-level fast).""" cdef int x, y cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size for x, y in squares: if not (0 <= x < size and 0 <= y < size) or grid_view[y, x] != 0: return True return False cdef bint is_cell_empty(self, int x, int y): """Check if a cell is empty (C-level fast, inlineable).""" cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size return 0 <= x < size and 0 <= y < size and grid_view[y, x] == 0 def get_cell(self, int x, int y) -> int: """Get the player index at a cell (0 = empty).""" cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size if 0 <= x < size and 0 <= y < size: return grid_view[y, x] return 0 def get_player_squares(self, int player_idx) -> list: """Get all squares occupied by a player.""" cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size cdef list result = [] cdef int x, y for y in range(size): for x in range(size): if grid_view[y, x] == player_idx: result.append((x, y)) return result def get_player_occupied(self, int player_idx) -> set: """Get all squares occupied by a player as a set.""" if player_idx not in self._occupied_cache: self._occupied_cache[player_idx] = set(self.get_player_squares(player_idx)) return self._occupied_cache[player_idx] def get_player_corners(self, int player_idx) -> set: """Get all corner cells adjacent to a player's pieces.""" cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size cdef set corners = set() cdef list player_squares = self.get_player_squares(player_idx) cdef int x, y, dx, dy, cx, cy for x, y in player_squares: for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]: cx, cy = x + dx, y + dy if 0 <= cx < size and 0 <= cy < size and grid_view[cy, cx] == 0: corners.add((cx, cy)) return corners def get_occupied(self) -> set: """Get all occupied cells.""" cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size cdef set result = set() cdef int x, y for y in range(size): for x in range(size): if grid_view[y, x] != 0: result.add((x, y)) return result def is_full(self) -> bool: """Check if the board is completely full.""" cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size cdef int x, y for y in range(size): for x in range(size): if grid_view[y, x] == 0: return False return True def coverage(self) -> float: """Get the fraction of the board that is occupied.""" cdef cnp.int8_t[:, :] grid_view = self.grid cdef int size = self.size cdef int count = 0 cdef int x, y for y in range(size): for x in range(size): if grid_view[y, x] != 0: count += 1 return count / (size * size) # ------------------------------------------------------------------- # Move and Piece classes # ------------------------------------------------------------------- cdef class Move: """A move in the game (piece, orientation, position).""" cdef public int piece_id cdef public int orientation_id cdef public int x cdef public int y def __init__(self, int piece_id, int orientation_id, int x, int y): self.piece_id = piece_id self.orientation_id = orientation_id self.x = x self.y = y def __repr__(self): return f"Move(piece={self.piece_id}, orient={self.orientation_id}, pos=({self.x}, {self.y}))" cdef class PieceOrientation: """A piece in a specific orientation (before placement on board).""" cdef public int piece_id cdef public int orientation_id cdef public list squares cdef public list corners def __init__(self, int piece_id, int orientation_id, list squares, list corners): self.piece_id = piece_id self.orientation_id = orientation_id self.squares = squares self.corners = corners cdef class Piece: """A polyomino piece defined by a set of (x, y) coordinates.""" cdef public str name cdef public object squares # Use object to allow frozenset def __init__(self, str name, object squares): self.name = name self.squares = squares @property def size(self) -> int: return len(self.squares) cdef class PieceSet: """A collection of pieces with pre-computed orientations.""" cdef public list pieces cdef public list piece_names cdef public dict piece_id_map cdef public list orientations cdef public int num_orientations def __init__(self, list pieces_list): self.pieces = pieces_list self.piece_names = [p.name for p in pieces_list] self.piece_id_map = {name: i for i, name in enumerate(self.piece_names)} # Pre-compute all orientations for each piece self.orientations = [] for piece_id, piece in enumerate(pieces_list): orients = generate_orientations_cython(piece) for orient in orients: orient.piece_id = piece_id self.orientations.append(orients) self.num_orientations = sum(len(o) for o in self.orientations) @property def num_pieces(self) -> int: return len(self.pieces) def get_orientations(self, int piece_id) -> list: return self.orientations[piece_id] def get_piece(self, int piece_id) -> Piece: return self.pieces[piece_id] def get_piece_id(self, str name) -> int: return self.piece_id_map[name] # ------------------------------------------------------------------- # Pre-computed standard pieces (Cython-optimized) # ------------------------------------------------------------------- cdef frozenset _I1_squares = frozenset([(0, 0)]) cdef frozenset _I2_squares = frozenset([(0, 0), (0, 1)]) cdef frozenset _I3_squares = frozenset([(0, 0), (0, 1), (0, 2)]) cdef frozenset _V3_squares = frozenset([(0, 0), (1, 0), (0, 1)]) cdef frozenset _I4_squares = frozenset([(0, 0), (0, 1), (0, 2), (0, 3)]) cdef frozenset _L4_squares = frozenset([(0, 0), (0, 1), (0, 2), (1, 0)]) cdef frozenset _T4_squares = frozenset([(0, 0), (1, 0), (2, 0), (1, 1)]) cdef frozenset _S4_squares = frozenset([(0, 0), (1, 0), (1, 1), (2, 1)]) cdef frozenset _O4_squares = frozenset([(0, 0), (1, 0), (0, 1), (1, 1)]) cdef frozenset _I5_squares = frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)]) cdef frozenset _L5_squares = frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 0)]) cdef frozenset _Y5_squares = frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 1)]) cdef frozenset _N5_squares = frozenset([(0, 0), (1, 0), (2, 0), (2, 1), (3, 1)]) cdef frozenset _T5_squares = frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, 2)]) cdef frozenset _U5_squares = frozenset([(0, 0), (2, 0), (0, 1), (1, 1), (2, 1)]) cdef frozenset _V5_squares = frozenset([(0, 0), (0, 1), (0, 2), (1, 0), (2, 0)]) cdef frozenset _W5_squares = frozenset([(0, 0), (0, 1), (1, 0), (1, 1), (2, 0)]) cdef frozenset _Z5_squares = frozenset([(0, 0), (1, 0), (1, 1), (1, 2), (2, 2)]) cdef frozenset _F5_squares = frozenset([(0, 0), (1, 0), (1, 1), (2, 1), (1, 2)]) cdef frozenset _X5_squares = frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, -1)]) cdef frozenset _P5_squares = frozenset([(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)]) cdef Piece _I1 = Piece("I1", _I1_squares) cdef Piece _I2 = Piece("I2", _I2_squares) cdef Piece _I3 = Piece("I3", _I3_squares) cdef Piece _V3 = Piece("V3", _V3_squares) cdef Piece _I4 = Piece("I4", _I4_squares) cdef Piece _L4 = Piece("L4", _L4_squares) cdef Piece _T4 = Piece("T4", _T4_squares) cdef Piece _S4 = Piece("S4", _S4_squares) cdef Piece _O4 = Piece("O4", _O4_squares) cdef Piece _I5 = Piece("I5", _I5_squares) cdef Piece _L5 = Piece("L5", _L5_squares) cdef Piece _Y5 = Piece("Y5", _Y5_squares) cdef Piece _N5 = Piece("N5", _N5_squares) cdef Piece _T5 = Piece("T5", _T5_squares) cdef Piece _U5 = Piece("U5", _U5_squares) cdef Piece _V5 = Piece("V5", _V5_squares) cdef Piece _W5 = Piece("W5", _W5_squares) cdef Piece _Z5 = Piece("Z5", _Z5_squares) cdef Piece _F5 = Piece("F5", _F5_squares) cdef Piece _X5 = Piece("X5", _X5_squares) cdef Piece _P5 = Piece("P5", _P5_squares) cdef list STANDARD_PIECES = [ _I1, _I2, _I3, _V3, _I4, _L4, _T4, _S4, _O4, _I5, _L5, _Y5, _N5, _T5, _U5, _V5, _W5, _Z5, _F5, _X5, _P5 ] def get_standard_pieces(): """Python-accessible function to get the standard piece set.""" return list(STANDARD_PIECES) # ------------------------------------------------------------------- # Orientation generation # ------------------------------------------------------------------- cdef list generate_orientations_cython(Piece piece): """Generate all unique orientations of a piece.""" cdef set orientations_set = set() cdef list result = [] cdef list squares = list(piece.squares) cdef list transformed cdef int i for i in range(4): # 4 rotations (0, 90, 180, 270 degrees) transformed = rotate_squares_cython(squares, i) normalized = normalize_squares_cython(transformed) key = tuple(normalized) if key not in orientations_set: orientations_set.add(key) corners = compute_corners_cython(normalized) result.append(PieceOrientation(0, i, normalized, corners)) return result cdef list rotate_squares_cython(list squares, int rotations): """Rotate squares by 90 * rotations degrees.""" cdef list result = [] cdef int x, y, i for x, y in squares: for i in range(rotations): x, y = -y, x # 90 degree rotation result.append((x, y)) return result cdef list normalize_squares_cython(list squares): """Normalize squares to start at (0, 0).""" cdef int min_x = min(x for x, _ in squares) cdef int min_y = min(y for _, y in squares) return [(x - min_x, y - min_y) for x, y in squares] cdef list compute_corners_cython(list squares): """Compute corner squares (diagonal neighbors).""" cdef set corners = set() cdef int x, y, dx, dy for x, y in squares: for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]: corners.add((x + dx, y + dy)) return list(corners) # ------------------------------------------------------------------- # Player state # ------------------------------------------------------------------- cdef class PlayerState: """State for a single player.""" cdef public int idx cdef public set available_pieces cdef public set corners cdef public int score cdef public bint has_started cdef public bint can_move def __init__(self): self.idx = -1 self.available_pieces = set() self.corners = set() self.score = 0 self.has_started = False self.can_move = True # ------------------------------------------------------------------- # Main Game class # ------------------------------------------------------------------- cdef class BlokusGame: """Cython-optimized Blokus game engine.""" cdef public int board_size cdef public PieceSet piece_set cdef public int num_players cdef public bint corner_rule cdef public object board cdef public list players cdef public int current_player cdef public int rounds cdef public bint game_over cdef public list _action_moves cdef public dict _move_to_action cdef public list _actions_by_piece cdef public list _starting_corners def __init__(self, int board_size=20, PieceSet pieces=None, int num_players=4, bint corner_rule=True): self.board_size = board_size self.piece_set = pieces or PieceSet(STANDARD_PIECES) self.num_players = num_players self.corner_rule = corner_rule self.board = Board(board_size) self.players = [] self.current_player = 0 self.rounds = 0 self.game_over = False # Pre-compute all possible moves self._action_moves = [] self._move_to_action = {} self._generate_action_space() # Pre-compute action indices per piece self._actions_by_piece = [] for piece_id in range(self.piece_set.num_pieces): self._actions_by_piece.append([ idx for idx, move in enumerate(self._action_moves) if move.piece_id == piece_id ]) # Starting corners max_idx = board_size - 1 self._starting_corners = [ (0, 0), (0, max_idx), (max_idx, 0), (max_idx, max_idx), ] if num_players == 2: self._starting_corners = [(0, 0), (max_idx, max_idx)] elif num_players == 3: self._starting_corners = [(0, 0), (0, max_idx), (max_idx, 0)] # Initialize players for i in range(self.num_players): player = PlayerState() player.idx = i player.available_pieces = set(self.piece_set.piece_names) if self.corner_rule and i < len(self._starting_corners): player.corners = {self._starting_corners[i]} else: player.corners = set() player.score = 0 player.has_started = False player.can_move = True self.players.append(player) cdef void _generate_action_space(self): """Pre-compute all possible (piece, orientation, position) moves.""" cdef int piece_id, orient_id cdef PieceOrientation orient cdef list squares cdef int max_x, max_y cdef int px, py cdef Move move cdef int action_idx self._action_moves = [] self._move_to_action = {} for piece_id in range(self.piece_set.num_pieces): for orient_id, orient in enumerate(self.piece_set.get_orientations(piece_id)): squares = orient.squares max_x = max(x for x, _ in squares) max_y = max(y for _, y in squares) for px in range(self.board_size - max_x): for py in range(self.board_size - max_y): move = Move(piece_id, orient_id, px, py) action_idx = len(self._action_moves) self._action_moves.append(move) self._move_to_action[(piece_id, orient_id, px, py)] = action_idx @property def num_actions(self) -> int: return len(self._action_moves) def get_move(self, int action) -> Move: return self._action_moves[action] def get_action(self, Move move) -> int: return self._move_to_action[(move.piece_id, move.orientation_id, move.x, move.y)] def reset(self) -> None: """Reset the game to its initial state.""" self.board = Board(self.board_size) self.players = [] self.current_player = 0 self.rounds = 0 self.game_over = False for i in range(self.num_players): player = PlayerState() player.idx = i player.available_pieces = set(self.piece_set.piece_names) if self.corner_rule and i < len(self._starting_corners): player.corners = {self._starting_corners[i]} else: player.corners = set() player.score = 0 player.has_started = False player.can_move = True self.players.append(player) cdef list _get_placed_squares(self, Move move): """Get the absolute board coordinates for a move.""" cdef PieceOrientation orient = self.piece_set.get_orientations(move.piece_id)[move.orientation_id] cdef list result = [] cdef int dx, dy for dx, dy in orient.squares: result.append((move.x + dx, move.y + dy)) return result cpdef list get_placed_squares(self, Move move): """Python-accessible wrapper for _get_placed_squares.""" return self._get_placed_squares(move) cdef list _get_placed_corners(self, Move move): """Get the absolute corner coordinates for a move.""" cdef PieceOrientation orient = self.piece_set.get_orientations(move.piece_id)[move.orientation_id] cdef list result = [] cdef int dx, dy for dx, dy in orient.corners: result.append((move.x + dx, move.y + dy)) return result cpdef list get_placed_corners(self, Move move): """Python-accessible wrapper for _get_placed_corners.""" return self._get_placed_corners(move) cpdef bint valid_move(self, int player_idx, Move move): """Check if a move is valid for the given player.""" cdef PlayerState player = self.players[player_idx] cdef Piece piece = self.piece_set.get_piece(move.piece_id) cdef list placed_squares = self._get_placed_squares(move) cdef list placed_corners = self._get_placed_corners(move) cdef int x, y cdef int player_board_idx = player_idx + 1 cdef set player_occupied cdef bint result # Rule 1: Check if player has the piece if piece.name not in player.available_pieces: return False # Rule 2: Check bounds (inline for speed) for x, y in placed_squares: if not self.board.in_bounds(x, y): return False # Rule 3: Check overlap if self.board.has_overlap(placed_squares): return False # Rule 6: First move must be in a corner if self.corner_rule and not player.has_started: if len(self.players) <= len(self._starting_corners): start_corner = self._starting_corners[player_idx] if (move.x, move.y) != start_corner: return False # Rule 5: No edge adjacency with same-color pieces (inline for speed) for x, y in placed_squares: for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: if self.board.in_bounds(x + dx, y + dy): if self.board.get_cell(x + dx, y + dy) == player_board_idx: return False # Rule 4: Corner rule (must touch same-color at a corner) if player.has_started: player_occupied = self.board.get_player_occupied(player_board_idx) # Manual any() implementation - Cython doesn't support generator expressions result = False for cx, cy in placed_corners: if self.board.in_bounds(cx, cy) and (cx, cy) in player_occupied: result = True break if not result: return False return True cpdef get_valid_actions(self, int player_idx): """Get a boolean mask of valid actions for the given player.""" cdef PlayerState player = self.players[player_idx] cdef cnp.ndarray mask = np.zeros(self.num_actions, dtype=bool) cdef list available_piece_ids = [] cdef int piece_id, action_idx cdef Move move cdef list placed_corners, placed_squares cdef int x, y, player_board_idx = player_idx + 1 cdef set player_occupied cdef bint edge_invalid, result cdef list corners_list cdef int cx, cy # Build available_piece_ids list for name in player.available_pieces: available_piece_ids.append(self.piece_set.get_piece_id(name)) if not player.can_move: return mask if not player.has_started or not player.corners: for piece_id in available_piece_ids: for action_idx in self._actions_by_piece[piece_id]: if self.valid_move(player_idx, self._action_moves[action_idx]): mask[action_idx] = True return mask # Pre-compute player's occupied squares ONCE player_occupied = self.board.get_player_occupied(player_idx + 1) for piece_id in available_piece_ids: for action_idx in self._actions_by_piece[piece_id]: move = self._action_moves[action_idx] placed_corners = self._get_placed_corners(move) # Early exit: check corner touch (manual any()) result = False for cx, cy in placed_corners: if self.board.in_bounds(cx, cy) and (cx, cy) in player_occupied: result = True break if not result: continue placed_squares = self._get_placed_squares(move) # Check bounds result = True for x, y in placed_squares: if not self.board.in_bounds(x, y): result = False break if not result: continue # Check overlap if self.board.has_overlap(placed_squares): continue # Check edge adjacency (inline for speed) edge_invalid = False for x, y in placed_squares: for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: if self.board.in_bounds(x + dx, y + dy): if self.board.get_cell(x + dx, y + dy) == player_board_idx: edge_invalid = True break if edge_invalid: break if not edge_invalid: mask[action_idx] = True return mask cpdef bint has_valid_moves(self, int player_idx): """Check if a player has any valid moves.""" cdef PlayerState player = self.players[player_idx] cdef list available_piece_ids = [] cdef int piece_id, action_idx # Build available_piece_ids list for name in player.available_pieces: available_piece_ids.append(self.piece_set.get_piece_id(name)) if not player.can_move: return False for piece_id in available_piece_ids: for action_idx in self._actions_by_piece[piece_id]: if self.valid_move(player_idx, self._action_moves[action_idx]): return True return False def apply_move(self, int player_idx, Move move) -> None: """Apply a move to the game state. Does not validate.""" cdef PlayerState player = self.players[player_idx] cdef Piece piece = self.piece_set.get_piece(move.piece_id) cdef list placed_squares = self._get_placed_squares(move) cdef int x, y # Place the piece self.board.place(player_idx + 1, placed_squares) # Update player state player.available_pieces.discard(piece.name) player.score += piece.size # Update corners if not player.has_started: player.has_started = True player.corners = set() # Add new corners cdef list placed_corners = self._get_placed_corners(move) for x, y in placed_corners: if self.board.in_bounds(x, y) and self.board.is_empty(x, y): player.corners.add((x, y)) # Check game over self._check_game_over() def play_move(self, int player_idx, int action) -> bint: """Play an action for the given player. Validates first.""" cdef Move move = self._action_moves[action] if not self.valid_move(player_idx, move): return False self.apply_move(player_idx, move) return True def next_player(self): """Advance to the next player who can still move. If no players can move, the game is over. """ if self.game_over: return self.current_player for offset in range(1, self.num_players + 1): next_idx = (self.current_player + offset) % self.num_players next_player = self.players[next_idx] if next_player.can_move and self.has_valid_moves(next_idx): self.current_player = next_idx return next_idx # No one can move — game over self.game_over = True return self.current_player def _check_game_over(self) -> None: """Check if the game is over.""" cdef PlayerState player cdef int player_idx for player_idx, player in enumerate(self.players): if not self.has_valid_moves(player_idx): player.can_move = False # Game ends if all players can't move if all(not p.can_move for p in self.players): self.game_over = True elif all(p.has_started and not p.available_pieces for p in self.players): # All pieces played self.game_over = True def is_game_over(self) -> bool: """Check if the game is over (no player can make a move).""" if self.game_over: return True # Check if any player can still move cdef int i for i in range(self.num_players): if self.players[i].can_move and self.has_valid_moves(i): return False self.game_over = True return True def get_scores(self) -> list: """Get the score for each player. Score = squares placed (positive) - unplaced squares (negative). A player who placed all pieces gets a +15 bonus. If the last piece was the monomino, an additional +5 bonus. """ cdef list scores = [] cdef PlayerState player cdef int unplaced cdef int score cdef int pid for player in self.players: # Sum sizes of remaining pieces unplaced = 0 for pid in range(self.piece_set.num_pieces): piece = self.piece_set.get_piece(pid) if piece.name in player.available_pieces: unplaced += piece.size score = player.score - unplaced # Bonus for placing all pieces if len(player.available_pieces) == 0: score += 15 scores.append(score) return scores def get_winners(self) -> list | None: """Get the winning player(s). Returns None if game is not over.""" if not self.is_game_over(): return None cdef list scores = self.get_scores() cdef int max_score = max(scores) return [i for i, s in enumerate(scores) if s == max_score] def get_state(self) -> dict: """Get the current game state as a dictionary.""" return { "board_size": self.board_size, "num_players": self.num_players, "current_player": self.current_player, "rounds": self.rounds, "game_over": self.game_over, "players": [ { "idx": p.idx, "available_pieces": list(p.available_pieces), "score": p.score, "has_started": p.has_started, "can_move": p.can_move, } for p in self.players ], } def copy(self) -> BlokusGame: """Create a deep copy of the game.""" cdef BlokusGame new_game = BlokusGame( board_size=self.board_size, pieces=self.piece_set, num_players=self.num_players, corner_rule=self.corner_rule ) new_game.board = self.board.copy() new_game.current_player = self.current_player new_game.rounds = self.rounds new_game.game_over = self.game_over for i, p in enumerate(self.players): new_game.players[i].available_pieces = p.available_pieces.copy() new_game.players[i].corners = p.corners.copy() new_game.players[i].score = p.score new_game.players[i].has_started = p.has_started new_game.players[i].can_move = p.can_move return new_game