feat: migrate web UI to Cython-optimized game engine

- Add cpdef wrappers (get_placed_squares, get_placed_corners) for Python
  accessibility of previously cdef-only methods
- Add get_standard_pieces() to expose Cython standard piece set
- Add is_game_over(), get_scores(), get_winners() to Cython BlokusGame
- Fix valid_move() player_occupied lookup (was using player_idx instead
  of player_idx+1, causing bots to fail after first move)
- Update session.py, app.py, bots.py to use Cython with pure Python fallback
- Fix board.grid.tolist() to np.array(grid).tolist() for memoryview compat
- Add Cython build artifacts to .gitignore
This commit is contained in:
mattlamb227@gmail.com
2026-08-14 00:17:31 -04:00
parent c14a98fbca
commit 37cc621020
5 changed files with 103 additions and 13 deletions
+5
View File
@@ -6,3 +6,8 @@ __pycache__/
.ruff_cache/ .ruff_cache/
*.egg-info/ *.egg-info/
runs/ runs/
# Cython build artifacts
*.so
*.o
blokus_cython.c
build/
+64 -1
View File
@@ -308,6 +308,11 @@ cdef list STANDARD_PIECES = [
] ]
def get_standard_pieces():
"""Python-accessible function to get the standard piece set."""
return list(STANDARD_PIECES)
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Orientation generation # Orientation generation
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@@ -528,6 +533,10 @@ cdef class BlokusGame:
result.append((move.x + dx, move.y + dy)) result.append((move.x + dx, move.y + dy))
return result 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): cdef list _get_placed_corners(self, Move move):
"""Get the absolute corner coordinates for a move.""" """Get the absolute corner coordinates for a move."""
cdef PieceOrientation orient = self.piece_set.get_orientations(move.piece_id)[move.orientation_id] cdef PieceOrientation orient = self.piece_set.get_orientations(move.piece_id)[move.orientation_id]
@@ -537,6 +546,10 @@ cdef class BlokusGame:
result.append((move.x + dx, move.y + dy)) result.append((move.x + dx, move.y + dy))
return result 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): cpdef bint valid_move(self, int player_idx, Move move):
"""Check if a move is valid for the given player.""" """Check if a move is valid for the given player."""
cdef PlayerState player = self.players[player_idx] cdef PlayerState player = self.players[player_idx]
@@ -577,7 +590,7 @@ cdef class BlokusGame:
# Rule 4: Corner rule (must touch same-color at a corner) # Rule 4: Corner rule (must touch same-color at a corner)
if player.has_started: if player.has_started:
player_occupied = self.board.get_player_occupied(player_idx) player_occupied = self.board.get_player_occupied(player_board_idx)
# Manual any() implementation - Cython doesn't support generator expressions # Manual any() implementation - Cython doesn't support generator expressions
result = False result = False
for cx, cy in placed_corners: for cx, cy in placed_corners:
@@ -757,6 +770,56 @@ cdef class BlokusGame:
# All pieces played # All pieces played
self.game_over = True 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: def get_state(self) -> dict:
"""Get the current game state as a dictionary.""" """Get the current game state as a dictionary."""
return { return {
+5 -2
View File
@@ -5,7 +5,10 @@ from abc import ABC, abstractmethod
import numpy as np import numpy as np
from blokus_gym.core.game import BlokusGame try:
from blokus_gym.core.blokus_cython import BlokusGame
except ImportError:
from blokus_gym.core.game import BlokusGame
class Bot(ABC): class Bot(ABC):
@@ -76,7 +79,7 @@ class GreedyCornersBot(Bot):
for action in valid_indices: for action in valid_indices:
move = game.get_move(int(action)) move = game.get_move(int(action))
piece = game.piece_set.get_piece(move.piece_id) piece = game.piece_set.get_piece(move.piece_id)
placed_corners = game._get_placed_corners(move) placed_corners = game.get_placed_corners(move)
# Count how many new corners this move opens # Count how many new corners this move opens
new_corners = 0 new_corners = 0
+13 -4
View File
@@ -2,10 +2,19 @@ from __future__ import annotations
import uuid import uuid
import numpy as np
from flask import Flask, jsonify, redirect, render_template, request, url_for from flask import Flask, jsonify, redirect, render_template, request, url_for
from blokus_gym.core.game import BlokusGame try:
from blokus_gym.core.pieces import STANDARD_PIECES, PieceSet from blokus_gym.core.blokus_cython import (
BlokusGame,
PieceSet,
get_standard_pieces,
)
STANDARD_PIECES = get_standard_pieces()
except ImportError:
from blokus_gym.core.game import BlokusGame
from blokus_gym.core.pieces import STANDARD_PIECES, PieceSet
from blokus_ui.session import GameSession from blokus_ui.session import GameSession
from blokus_ui.store import RunStore from blokus_ui.store import RunStore
@@ -115,7 +124,7 @@ def replay_page(run_id):
game.reset() game.reset()
states = [{ states = [{
"board": game.board.grid.tolist(), "board": np.array(game.board.grid).tolist(),
"current_player": game.current_player, "current_player": game.current_player,
"scores": game.get_scores(), "scores": game.get_scores(),
}] }]
@@ -124,7 +133,7 @@ def replay_page(run_id):
game.play_move(move["player_idx"], move["action"]) game.play_move(move["player_idx"], move["action"])
game.next_player() game.next_player()
states.append({ states.append({
"board": game.board.grid.tolist(), "board": np.array(game.board.grid).tolist(),
"current_player": game.current_player, "current_player": game.current_player,
"scores": game.get_scores(), "scores": game.get_scores(),
}) })
+14 -4
View File
@@ -11,8 +11,18 @@ from blokus_gym.core.bots import (
MinimaxBot, MinimaxBot,
RandomBot, RandomBot,
) )
from blokus_gym.core.game import BlokusGame
from blokus_gym.core.pieces import STANDARD_PIECES, PieceSet try:
from blokus_gym.core.blokus_cython import (
BlokusGame,
PieceSet,
Move,
get_standard_pieces,
)
STANDARD_PIECES = get_standard_pieces()
except ImportError:
from blokus_gym.core.game import BlokusGame
from blokus_gym.core.pieces import STANDARD_PIECES, PieceSet, Move
class GameSession: class GameSession:
@@ -145,7 +155,7 @@ class GameSession:
move = self.game.get_move(int(action_idx)) move = self.game.get_move(int(action_idx))
if move.piece_id != piece_id: if move.piece_id != piece_id:
continue continue
cells = self.game._get_placed_squares(move) cells = self.game.get_placed_squares(move)
placements.append( placements.append(
{ {
"action": int(action_idx), "action": int(action_idx),
@@ -181,7 +191,7 @@ class GameSession:
return {"success": True} return {"success": True}
def get_state(self) -> dict[str, Any]: def get_state(self) -> dict[str, Any]:
board = self.game.board.grid.tolist() board = np.array(self.game.board.grid).tolist()
scores = self.game.get_scores() scores = self.game.get_scores()
players = [] players = []