updated to use optimized cyython build

This commit is contained in:
mattlamb227@gmail.com
2026-08-13 23:09:08 -04:00
parent 3657a820ba
commit c14a98fbca
26 changed files with 62307 additions and 110 deletions
-35
View File
@@ -1,35 +0,0 @@
Metadata-Version: 2.4
Name: blokus-gym
Version: 0.1.0
Summary: A Gymnasium-compatible RL environment for the board game Blokus, with a playable web UI
Author: Blokus Gym Contributors
License: MIT
Project-URL: Homepage, https://github.com/blokus-gym/blokus-gym
Project-URL: Documentation, https://github.com/blokus-gym/blokus-gym#readme
Keywords: reinforcement-learning,gymnasium,blokus,board-game,pettingzoo,flask,game-ui
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: numpy>=1.24
Requires-Dist: gymnasium>=0.29
Requires-Dist: pettingzoo>=1.26
Provides-Extra: render
Requires-Dist: matplotlib>=3.7; extra == "render"
Provides-Extra: ui
Requires-Dist: flask>=3.0; extra == "ui"
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: pytest-cov>=4.1; extra == "dev"
Requires-Dist: black>=23.7; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.5; extra == "dev"
Provides-Extra: train
Requires-Dist: stable-baselines3>=2.2; extra == "train"
Requires-Dist: torch>=2.0; extra == "train"
-30
View File
@@ -1,30 +0,0 @@
pyproject.toml
src/blokus_gym/__init__.py
src/blokus_gym.egg-info/PKG-INFO
src/blokus_gym.egg-info/SOURCES.txt
src/blokus_gym.egg-info/dependency_links.txt
src/blokus_gym.egg-info/entry_points.txt
src/blokus_gym.egg-info/requires.txt
src/blokus_gym.egg-info/top_level.txt
src/blokus_gym/core/__init__.py
src/blokus_gym/core/board.py
src/blokus_gym/core/bots.py
src/blokus_gym/core/game.py
src/blokus_gym/core/pieces.py
src/blokus_gym/envs/__init__.py
src/blokus_gym/envs/blokus_env.py
src/blokus_gym/envs/multiagent.py
src/blokus_gym/utils/__init__.py
src/blokus_gym/utils/render.py
src/blokus_gym/wrappers/__init__.py
src/blokus_gym/wrappers/action_mask.py
src/blokus_ui/__init__.py
src/blokus_ui/app.py
src/blokus_ui/session.py
src/blokus_ui/store.py
tests/test_board.py
tests/test_envs.py
tests/test_game.py
tests/test_imports.py
tests/test_pieces.py
tests/test_ui.py
@@ -1 +0,0 @@
-20
View File
@@ -1,20 +0,0 @@
numpy>=1.24
gymnasium>=0.29
pettingzoo>=1.26
[dev]
pytest>=7.4
pytest-cov>=4.1
black>=23.7
ruff>=0.1.0
mypy>=1.5
[render]
matplotlib>=3.7
[train]
stable-baselines3>=2.2
torch>=2.0
[ui]
flask>=3.0
-2
View File
@@ -1,2 +0,0 @@
blokus_gym
blokus_ui
Binary file not shown.
+21 -4
View File
@@ -1,7 +1,24 @@
from blokus_gym.core.board import Board # Try to import Cython version first, fall back to Python
from blokus_gym.core.bots import Bot, GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot try:
from blokus_gym.core.game import BlokusGame, Move from .blokus_cython import (
from blokus_gym.core.pieces import ( Board,
BlokusGame,
Move,
Piece,
PieceOrientation,
PieceSet,
)
# Import STANDARD_PIECES from pieces.py since it's not in Cython
from .pieces import STANDARD_PIECES, DUO_PIECES, JUNIOR_PIECES
# Import generate_orientations from pieces.py
from .pieces import generate_orientations
# Import bots from Python since they're not in Cython
from .bots import Bot, GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
except ImportError:
from .board import Board
from .bots import Bot, GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
from .game import BlokusGame, Move
from .pieces import (
DUO_PIECES, DUO_PIECES,
JUNIOR_PIECES, JUNIOR_PIECES,
STANDARD_PIECES, STANDARD_PIECES,
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
+801
View File
@@ -0,0 +1,801 @@
#!/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
]
# -------------------------------------------------------------------
# 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
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 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_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 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
+10 -8
View File
@@ -49,18 +49,20 @@ class Board:
"""Get the player index at a cell (0 = empty).""" """Get the player index at a cell (0 = empty)."""
return self.grid[y, x] return self.grid[y, x]
def get_player_squares(self, player_idx: int) -> list[tuple[int, int]]: def get_player_squares(self, player_idx: int, player_board_idx: int | None = None) -> list[tuple[int, int]]:
"""Get all squares occupied by a player.""" """Get all squares occupied by a player."""
ys, xs = np.where(self.grid == player_idx) if player_board_idx is None:
player_board_idx = player_idx + 1 # Default: PLAYER_OFFSET = 1
ys, xs = np.where(self.grid == player_board_idx)
return [(int(x), int(y)) for x, y in zip(xs, ys, strict=True)] return [(int(x), int(y)) for x, y in zip(xs, ys, strict=True)]
def get_player_occupied(self, player_idx: int) -> set[tuple[int, int]]: def get_player_occupied(self, player_board_idx: int) -> set[tuple[int, int]]:
"""Get all squares occupied by a player as a set for fast lookup.""" """Get all squares occupied by a player as a set for fast lookup."""
if player_idx not in self._occupied_cache: if player_board_idx not in self._occupied_cache:
self._occupied_cache[player_idx] = set(self.get_player_squares(player_idx)) self._occupied_cache[player_board_idx] = set(self.get_player_squares(player_board_idx - 1, player_board_idx))
return self._occupied_cache[player_idx] return self._occupied_cache[player_board_idx]
def get_player_corners(self, player_idx: int) -> set[tuple[int, int]]: def get_player_corners(self, player_board_idx: int) -> set[tuple[int, int]]:
"""Get all corner cells adjacent to a player's pieces. """Get all corner cells adjacent to a player's pieces.
Corner cells are the diagonal neighbors of a player's pieces. Corner cells are the diagonal neighbors of a player's pieces.
@@ -68,7 +70,7 @@ class Board:
(corner-to-corner contact rule). (corner-to-corner contact rule).
""" """
corners: set[tuple[int, int]] = set() corners: set[tuple[int, int]] = set()
player_squares = self.get_player_squares(player_idx) player_squares = self.get_player_squares(player_board_idx - 1, player_board_idx)
for x, y in player_squares: for x, y in player_squares:
for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]: for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
cx, cy = x + dx, y + dy cx, cy = x + dx, y + dy
+2 -2
View File
@@ -207,7 +207,7 @@ class BlokusGame:
if player.has_started: if player.has_started:
placed_corners = self._get_placed_corners(move) placed_corners = self._get_placed_corners(move)
# Use cached occupied squares from board for fast lookup # Use cached occupied squares from board for fast lookup
player_occupied = self.board.get_player_occupied(player_idx) player_occupied = self.board.get_player_occupied(player_board_idx)
touches_corner = any( touches_corner = any(
self.board.in_bounds(cx, cy) and (cx, cy) in player_occupied self.board.in_bounds(cx, cy) and (cx, cy) in player_occupied
for cx, cy in placed_corners for cx, cy in placed_corners
@@ -240,7 +240,7 @@ class BlokusGame:
player_board_idx = player_idx + self.PLAYER_OFFSET player_board_idx = player_idx + self.PLAYER_OFFSET
# Pre-compute player's occupied squares ONCE (cached) # Pre-compute player's occupied squares ONCE (cached)
player_occupied = self.board.get_player_occupied(player_idx) player_occupied = self.board.get_player_occupied(player_board_idx)
for piece_id in available_piece_ids: for piece_id in available_piece_ids:
for action_idx in self._actions_by_piece[piece_id]: for action_idx in self._actions_by_piece[piece_id]:
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Setup script for compiling Cython extensions."""
from setuptools import setup, Extension
from Cython.Build import cythonize
import numpy as np
# Define extensions with proper include directories
extensions = [
Extension(
"blokus_cython",
["blokus_cython.pyx"],
include_dirs=[np.get_include()],
define_macros=[("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION")],
),
]
setup(
ext_modules=cythonize(
extensions,
compiler_directives={"language_level": "3"},
annotate=False,
),
include_dirs=[np.get_include()],
)
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Setup script for blokus_gym package."""
from setuptools import setup, Extension
from Cython.Build import cythonize
import numpy as np
# Define extensions with proper include directories
extensions = [
Extension(
"blokus_gym.core.blokus_cython",
["blokus_gym/core/blokus_cython.pyx"],
include_dirs=[np.get_include()],
define_macros=[("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION")],
),
]
setup(
name="blokus_gym",
ext_modules=cythonize(
extensions,
compiler_directives={"language_level": "3"},
annotate=False,
),
include_dirs=[np.get_include()],
)