From 37cc6210200500fc7dc1ec12f8bbe754e3667be5 Mon Sep 17 00:00:00 2001 From: "mattlamb227@gmail.com" Date: Fri, 14 Aug 2026 00:17:31 -0400 Subject: [PATCH] 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 --- .gitignore | 5 ++ src/blokus_gym/core/blokus_cython.pyx | 69 +++++++++++++++++++++++++-- src/blokus_gym/core/bots.py | 7 ++- src/blokus_ui/app.py | 17 +++++-- src/blokus_ui/session.py | 18 +++++-- 5 files changed, 103 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index dfa08b5..39aef53 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ __pycache__/ .ruff_cache/ *.egg-info/ runs/ +# Cython build artifacts +*.so +*.o +blokus_cython.c +build/ diff --git a/src/blokus_gym/core/blokus_cython.pyx b/src/blokus_gym/core/blokus_cython.pyx index abe8a62..4c54e9f 100644 --- a/src/blokus_gym/core/blokus_cython.pyx +++ b/src/blokus_gym/core/blokus_cython.pyx @@ -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 # ------------------------------------------------------------------- @@ -527,7 +532,11 @@ cdef class BlokusGame: 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] @@ -536,7 +545,11 @@ cdef class BlokusGame: 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] @@ -577,7 +590,7 @@ cdef class BlokusGame: # Rule 4: Corner rule (must touch same-color at a corner) 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 result = False for cx, cy in placed_corners: @@ -756,6 +769,56 @@ cdef class BlokusGame: 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.""" diff --git a/src/blokus_gym/core/bots.py b/src/blokus_gym/core/bots.py index 27901b4..1760be4 100644 --- a/src/blokus_gym/core/bots.py +++ b/src/blokus_gym/core/bots.py @@ -5,7 +5,10 @@ from abc import ABC, abstractmethod 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): @@ -76,7 +79,7 @@ class GreedyCornersBot(Bot): for action in valid_indices: move = game.get_move(int(action)) 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 new_corners = 0 diff --git a/src/blokus_ui/app.py b/src/blokus_ui/app.py index d095ac9..85de3b6 100644 --- a/src/blokus_ui/app.py +++ b/src/blokus_ui/app.py @@ -2,10 +2,19 @@ from __future__ import annotations import uuid +import numpy as np from flask import Flask, jsonify, redirect, render_template, request, url_for -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, + 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.store import RunStore @@ -115,7 +124,7 @@ def replay_page(run_id): game.reset() states = [{ - "board": game.board.grid.tolist(), + "board": np.array(game.board.grid).tolist(), "current_player": game.current_player, "scores": game.get_scores(), }] @@ -124,7 +133,7 @@ def replay_page(run_id): game.play_move(move["player_idx"], move["action"]) game.next_player() states.append({ - "board": game.board.grid.tolist(), + "board": np.array(game.board.grid).tolist(), "current_player": game.current_player, "scores": game.get_scores(), }) diff --git a/src/blokus_ui/session.py b/src/blokus_ui/session.py index f24651d..31041cd 100644 --- a/src/blokus_ui/session.py +++ b/src/blokus_ui/session.py @@ -11,8 +11,18 @@ from blokus_gym.core.bots import ( MinimaxBot, 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: @@ -145,7 +155,7 @@ class GameSession: move = self.game.get_move(int(action_idx)) if move.piece_id != piece_id: continue - cells = self.game._get_placed_squares(move) + cells = self.game.get_placed_squares(move) placements.append( { "action": int(action_idx), @@ -181,7 +191,7 @@ class GameSession: return {"success": True} 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() players = []