Compare commits

..
10 Commits
Author SHA1 Message Date
mattlamb227@gmail.com 946196f2eb Initial commit 2026-08-14 13:59:56 -04:00
mattlamb227@gmail.com 37cc621020 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
2026-08-14 00:17:31 -04:00
mattlamb227@gmail.com c14a98fbca updated to use optimized cyython build 2026-08-13 23:09:08 -04:00
mattlamb227@gmail.com 3657a820ba Update cache and egg-info files 2026-08-13 00:17:27 -04:00
mattlamb227@gmail.com 3b2ffa28d4 Optimize: Cache player occupied squares for faster corner validation 2026-08-12 23:39:52 -04:00
mattlamb227@gmail.com 10d8b0a376 feat: auto-save replays on game over and page unload
- Add autoSave() function that saves replay silently on game over
- Add beforeunload listener to save incomplete games when navigating away
- Add prominent 'Save Now' button in status bar (visible during play)
- Show 'Replay auto-saved!' notification in status bar
- Add gameSaved flag to prevent duplicate saves
2026-08-09 22:11:50 -04:00
mattlamb227@gmail.com ba6e26ab59 fix: placement cells missing width/height and incorrect positioning
- Add width/height (30px) to .placement-cell CSS so cells are visible
- Account for CSS grid gap (1px) in placement cell positioning
- Set width/height dynamically in JS for consistency
2026-08-09 21:56:27 -04:00
mattlamb227@gmail.com 4a4eef679a fix: add action/method attributes to new game form 2026-08-09 21:35:14 -04:00
mattlamb227@gmail.com 70672e9fef feat: add playable Blokus web UI with replay system
- Add blokus_ui package: Flask web app for human-vs-bot Blokus
- GameSession: wraps BlokusGame for human play, records move history, serializes
- RunStore: file-based JSON replay storage with save/load/list/delete
- Flask app: routes for new game, play, save, browse runs, replay
- Templates: index (new game + run browser), game (playable board), replay (step controls)
- Static: CSS (dark theme, colored cells, placement overlays) + JS (click handling, API calls)
- Add blokus_ui tests (19 tests for GameSession and RunStore)
- Update pyproject.toml with flask dependency and blokus-ui entry point
- Fix: remove unreachable dead code in BlokusGame.valid_move
- All 102 tests pass, ruff lint clean
2026-08-09 20:08:18 -04:00
mattlamb227@gmail.com 123513e32f fix: remove unreachable dead code in BlokusGame.valid_move (game.py:220-260) 2026-08-09 18:18:46 -04:00
47 changed files with 65907 additions and 111 deletions
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.ruff_cache/
*.egg-info/
runs/
# Cython build artifacts
*.so
*.o
blokus_cython.c
build/
+6 -2
View File
@@ -5,12 +5,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "blokus-gym"
version = "0.1.0"
description = "A Gymnasium-compatible RL environment for the board game Blokus"
description = "A Gymnasium-compatible RL environment for the board game Blokus, with a playable web UI"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [{name = "Blokus Gym Contributors"}]
keywords = ["reinforcement-learning", "gymnasium", "blokus", "board-game", "pettingzoo"]
keywords = ["reinforcement-learning", "gymnasium", "blokus", "board-game", "pettingzoo", "flask", "game-ui"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research",
@@ -29,6 +29,7 @@ dependencies = [
[project.optional-dependencies]
render = ["matplotlib>=3.7"]
ui = ["flask>=3.0"]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
@@ -45,6 +46,9 @@ train = [
Homepage = "https://github.com/blokus-gym/blokus-gym"
Documentation = "https://github.com/blokus-gym/blokus-gym#readme"
[project.scripts]
blokus-ui = "blokus_ui.app:app"
[tool.setuptools.packages.find]
where = ["src"]
-33
View File
@@ -1,33 +0,0 @@
Metadata-Version: 2.4
Name: blokus-gym
Version: 0.1.0
Summary: A Gymnasium-compatible RL environment for the board game Blokus
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
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: 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"
-19
View File
@@ -1,19 +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/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
@@ -1 +0,0 @@
-17
View File
@@ -1,17 +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
-1
View File
@@ -1 +0,0 @@
blokus_gym
+22 -5
View File
@@ -1,7 +1,24 @@
from blokus_gym.core.board import Board
from blokus_gym.core.bots import Bot, GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
from blokus_gym.core.game import BlokusGame, Move
from blokus_gym.core.pieces import (
# Try to import Cython version first, fall back to Python
try:
from .blokus_cython 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,
JUNIOR_PIECES,
STANDARD_PIECES,
@@ -9,7 +26,7 @@ from blokus_gym.core.pieces import (
PieceOrientation,
PieceSet,
generate_orientations,
)
)
__all__ = [
"Board",
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
+864
View File
@@ -0,0 +1,864 @@
#!/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
+16 -4
View File
@@ -16,11 +16,15 @@ class Board:
def __init__(self, size: int):
self.size = size
self.grid = np.zeros((size, size), dtype=np.int8)
self._occupied_cache: dict[int, set[tuple[int, int]]] = {} # player_idx -> occupied squares
def place(self, player_idx: int, squares: list[tuple[int, int]]) -> None:
"""Place a player's piece on the board."""
for x, y in squares:
self.grid[y, x] = player_idx
# Update occupied cache incrementally instead of clearing
if player_idx in self._occupied_cache:
self._occupied_cache[player_idx].update(squares)
def clear(self) -> None:
"""Reset the board to empty."""
@@ -45,12 +49,20 @@ class Board:
"""Get the player index at a cell (0 = empty)."""
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."""
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)]
def get_player_corners(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."""
if player_board_idx not in self._occupied_cache:
self._occupied_cache[player_board_idx] = set(self.get_player_squares(player_board_idx - 1, player_board_idx))
return self._occupied_cache[player_board_idx]
def get_player_corners(self, player_board_idx: int) -> set[tuple[int, int]]:
"""Get all corner cells adjacent to a player's pieces.
Corner cells are the diagonal neighbors of a player's pieces.
@@ -58,7 +70,7 @@ class Board:
(corner-to-corner contact rule).
"""
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 dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
cx, cy = x + dx, y + dy
+5 -2
View File
@@ -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
+72 -17
View File
@@ -55,6 +55,14 @@ class BlokusGame:
self._move_to_action: dict[tuple[int, int, int, int], int] = {}
self._generate_action_space()
# Pre-compute action indices per piece for faster filtering
self._actions_by_piece: list[list[int]] = []
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 for each player (standard Blokus layout)
# 4 players: all four corners
# 2 players: opposite corners (0,0) and (max, max)
@@ -197,17 +205,15 @@ class BlokusGame:
# Rule 4: Corner rule (must touch same-color at a corner)
if player.has_started:
# Must touch at least one same-color piece at a corner
placed_corners = self._get_placed_corners(move)
touches_corner = False
for cx, cy in placed_corners:
if self.board.in_bounds(cx, cy):
if self.board.get_cell(cx, cy) == player_board_idx:
touches_corner = True
break
# Use cached occupied squares from board for fast lookup
player_occupied = self.board.get_player_occupied(player_board_idx)
touches_corner = any(
self.board.in_bounds(cx, cy) and (cx, cy) in player_occupied
for cx, cy in placed_corners
)
if not touches_corner:
return False
# If not started yet, first move doesn't need to touch (it's the first piece)
return True
@@ -219,12 +225,58 @@ class BlokusGame:
if not player.can_move:
return mask
for action_idx in range(self.num_actions):
available_piece_ids = [
self.piece_set.get_piece_id(name)
for name in player.available_pieces
]
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
player_board_idx = player_idx + self.PLAYER_OFFSET
# Pre-compute player's occupied squares ONCE (cached)
player_occupied = self.board.get_player_occupied(player_board_idx)
for piece_id in available_piece_ids:
for action_idx in self._actions_by_piece[piece_id]:
move = self._action_moves[action_idx]
piece = self.piece_set.get_piece(move.piece_id)
if piece.name not in player.available_pieces:
placed_corners = self._get_placed_corners(move)
# Early exit: check corner touch using cached occupied squares
touches_corner = any(
self.board.in_bounds(cx, cy) and (cx, cy) in player_occupied
for cx, cy in placed_corners
)
if not touches_corner:
continue # Skip expensive overlap/edge checks
placed_squares = self._get_placed_squares(move)
# Check bounds
if not all(self.board.in_bounds(x, y) for x, y in placed_squares):
continue
if self.valid_move(player_idx, move):
# Check overlap
if self.board.has_overlap(placed_squares):
continue
# Check edge adjacency (no same-color edges)
edge_invalid = False
for x, y in placed_squares:
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = x + dx, y + dy
if self.board.in_bounds(nx, ny) and self.board.get_cell(nx, ny) == player_board_idx:
edge_invalid = True
break
if edge_invalid:
break
if not edge_invalid:
mask[action_idx] = True
return mask
@@ -240,11 +292,14 @@ class BlokusGame:
if not player.can_move:
return False
for action_idx in range(self.num_actions):
move = self._action_moves[action_idx]
piece = self.piece_set.get_piece(move.piece_id)
if piece.name in player.available_pieces:
if self.valid_move(player_idx, move):
available_piece_ids = [
self.piece_set.get_piece_id(name)
for name in player.available_pieces
]
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
+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()],
)
+10
View File
@@ -0,0 +1,10 @@
from blokus_ui.session import GameSession
from blokus_ui.store import RunStore
__version__ = "0.1.0"
__all__ = [
"GameSession",
"RunStore",
"__version__",
]
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
import uuid
import numpy as np
from flask import Flask, jsonify, redirect, render_template, request, url_for
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
app = Flask(__name__)
SESSIONS: dict[str, GameSession] = {}
store = RunStore()
@app.route("/")
def index():
runs = store.list_runs()
return render_template("index.html", runs=runs)
@app.route("/game/new", methods=["POST"])
def new_game():
board_size = int(request.form.get("board_size", 20))
num_players = int(request.form.get("num_players", 4))
human_player = int(request.form.get("human_player", 0))
bot_type = request.form.get("bot_type", "greedy")
bot_seed = int(request.form.get("bot_seed", 42))
session = GameSession(
board_size=board_size,
num_players=num_players,
human_player=human_player,
bot_type=bot_type,
bot_seed=bot_seed,
)
session_id = str(uuid.uuid4())
SESSIONS[session_id] = session
return redirect(url_for("game_page", session_id=session_id))
@app.route("/game/<session_id>")
def game_page(session_id):
session = SESSIONS.get(session_id)
if session is None:
return "Game session not found", 404
return render_template(
"game.html",
session_id=session_id,
board_size=session.board_size,
)
@app.route("/api/game/<session_id>/state")
def game_state(session_id):
session = SESSIONS.get(session_id)
if session is None:
return jsonify({"error": "Session not found"}), 404
return jsonify(session.get_state())
@app.route("/api/game/<session_id>/placements")
def game_placements(session_id):
session = SESSIONS.get(session_id)
if session is None:
return jsonify({"error": "Session not found"}), 404
piece_id = int(request.args.get("piece_id", 0))
placements = session.get_valid_placements(piece_id)
return jsonify({"placements": placements})
@app.route("/api/game/<session_id>/move", methods=["POST"])
def game_move(session_id):
session = SESSIONS.get(session_id)
if session is None:
return jsonify({"error": "Session not found"}), 404
action = int(request.json.get("action", -1))
result = session.apply_human_action(action)
return jsonify(result)
@app.route("/api/game/<session_id>/save", methods=["POST"])
def save_game(session_id):
session = SESSIONS.get(session_id)
if session is None:
return jsonify({"error": "Session not found"}), 404
run_id = store.save(session)
return jsonify({"success": True, "run_id": run_id})
@app.route("/runs")
def runs_page():
runs = store.list_runs()
return render_template("index.html", runs=runs)
@app.route("/replay/<run_id>")
def replay_page(run_id):
run_data = store.load(run_id)
if run_data is None:
return "Run not found", 404
config = run_data["data"]["config"]
move_history = run_data["data"]["move_history"]
statistics = run_data["data"].get("statistics", {})
game = BlokusGame(
board_size=config["board_size"],
pieces=PieceSet(STANDARD_PIECES),
num_players=config["num_players"],
)
game.reset()
states = [{
"board": np.array(game.board.grid).tolist(),
"current_player": game.current_player,
"scores": game.get_scores(),
}]
for move in move_history:
game.play_move(move["player_idx"], move["action"])
game.next_player()
states.append({
"board": np.array(game.board.grid).tolist(),
"current_player": game.current_player,
"scores": game.get_scores(),
})
replay_data = {
"states": states,
"moves": move_history,
"config": config,
"statistics": statistics,
}
return render_template(
"replay.html",
run_id=run_id,
board_size=config["board_size"],
total_moves=len(move_history),
replay_data=replay_data,
)
@app.route("/runs/<run_id>/delete", methods=["POST"])
def delete_run(run_id):
store.delete(run_id)
return redirect(url_for("index"))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
+345
View File
@@ -0,0 +1,345 @@
from __future__ import annotations
from typing import Any
import numpy as np
from blokus_gym.core.bots import (
Bot,
GreedyBot,
GreedyCornersBot,
MinimaxBot,
RandomBot,
)
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:
"""Wraps BlokusGame for human-vs-bot play with move history and serialization.
Tracks which players are human vs bot, records every move for replay,
and provides methods to handle human input and auto-play bot turns.
"""
BOT_TYPES: dict[str, type[Bot]] = {
"random": RandomBot,
"greedy": GreedyBot,
"greedy_corners": GreedyCornersBot,
"minimax": MinimaxBot,
}
PLAYER_NAMES = {0: "Red", 1: "Blue", 2: "Yellow", 3: "Green"}
PLAYER_COLORS = {0: "red", 1: "blue", 2: "yellow", 3: "green"}
def __init__(
self,
board_size: int = 20,
num_players: int = 4,
human_player: int = 0,
bot_type: str = "greedy",
bot_seed: int = 42,
minimax_depth: int = 2,
):
assert 2 <= num_players <= 4
assert 0 <= human_player < num_players
assert bot_type in self.BOT_TYPES
self.board_size = board_size
self.num_players = num_players
self.human_player = human_player
self.bot_type_name = bot_type
self.bot_seed = bot_seed
self.minimax_depth = minimax_depth
self.game = BlokusGame(
board_size=board_size,
pieces=PieceSet(STANDARD_PIECES),
num_players=num_players,
)
self.game.reset()
self.move_history: list[dict[str, Any]] = []
self.game_over = False
self.winner: int | None = None
self.last_bot_move: dict[str, Any] | None = None
self._init_bots()
self._play_bots_until_human()
def _init_bots(self) -> None:
bot_class = self.BOT_TYPES[self.bot_type_name]
self.bots: dict[int, Bot] = {}
for i in range(self.num_players):
if i == self.human_player:
continue
if bot_class == MinimaxBot:
self.bots[i] = bot_class(
player_idx=i, depth=self.minimax_depth, seed=self.bot_seed + i * 1000
)
else:
self.bots[i] = bot_class(player_idx=i, seed=self.bot_seed + i * 1000)
def _play_bots_until_human(self) -> None:
max_turns = self.num_players * 20
turns = 0
while self.game.current_player != self.human_player and not self.game.is_game_over():
if turns > max_turns:
break
self._play_bot_turn()
turns += 1
def _play_bot_turn(self) -> None:
player_idx = self.game.current_player
bot = self.bots.get(player_idx)
if bot is None:
self.game.next_player()
return
valid_actions = self.game.get_valid_actions(player_idx)
if not np.any(valid_actions):
self.game.players[player_idx].can_move = False
self.game.next_player()
return
action = bot.select_action(self.game, valid_actions)
if action is not None:
self._record_move(player_idx, action)
self.game.play_move(player_idx, action)
self.last_bot_move = {
"player_idx": player_idx,
"piece_name": self.game.piece_set.get_piece(
self.game.get_move(action).piece_id
).name,
"x": self.game.get_move(action).x,
"y": self.game.get_move(action).y,
}
else:
self.game.players[player_idx].can_move = False
self.game.next_player()
def _record_move(self, player_idx: int, action: int) -> None:
move = self.game.get_move(action)
piece = self.game.piece_set.get_piece(move.piece_id)
self.move_history.append(
{
"player_idx": player_idx,
"action": action,
"piece_name": piece.name,
"piece_id": move.piece_id,
"orientation_id": move.orientation_id,
"x": move.x,
"y": move.y,
}
)
def get_valid_placements(self, piece_id: int) -> list[dict[str, Any]]:
if self.game.current_player != self.human_player or self.game_over:
return []
mask = self.game.get_valid_actions(self.human_player)
placements: list[dict[str, Any]] = []
for action_idx in np.where(mask)[0]:
move = self.game.get_move(int(action_idx))
if move.piece_id != piece_id:
continue
cells = self.game.get_placed_squares(move)
placements.append(
{
"action": int(action_idx),
"cells": [[x, y] for x, y in cells],
"x": move.x,
"y": move.y,
}
)
return placements
def apply_human_action(self, action: int) -> dict[str, Any]:
if self.game_over or self.game.current_player != self.human_player:
return {"success": False, "error": "Not human's turn"}
valid = self.game.get_valid_actions(self.human_player)
if action < 0 or action >= len(valid) or not valid[action]:
return {"success": False, "error": "Invalid action"}
self._record_move(self.human_player, action)
self.game.play_move(self.human_player, action)
self.game.next_player()
self._play_bots_until_human()
if self.game.is_game_over():
self.game_over = True
scores = self.game.get_scores()
max_score = max(scores)
winners = [i for i, s in enumerate(scores) if s == max_score]
self.winner = winners[0] if len(winners) == 1 else None
return {"success": True}
def get_state(self) -> dict[str, Any]:
board = np.array(self.game.board.grid).tolist()
scores = self.game.get_scores()
players = []
for i in range(self.num_players):
player = self.game.players[i]
players.append(
{
"idx": i,
"name": self.PLAYER_NAMES.get(i, f"Player {i + 1}"),
"color": self.PLAYER_COLORS.get(i, "grey"),
"is_human": i == self.human_player,
"pieces_remaining": len(player.available_pieces),
"total_pieces": len(self.game.piece_set.pieces),
"score": player.score,
}
)
human_player_state = self.game.players[self.human_player]
available_pieces = []
for piece in self.game.piece_set.pieces:
if piece.name in human_player_state.available_pieces:
min_x = min(s[0] for s in piece.squares)
min_y = min(s[1] for s in piece.squares)
available_pieces.append(
{
"id": self.game.piece_set.get_piece_id(piece.name),
"name": piece.name,
"size": piece.size,
"squares": [
[x - min_x, y - min_y] for x, y in sorted(piece.squares)
],
}
)
return {
"board": board,
"board_size": self.board_size,
"current_player": self.game.current_player,
"human_player": self.human_player,
"game_over": self.game_over,
"scores": scores,
"players": players,
"available_pieces": available_pieces,
"move_history": list(self.move_history),
"total_moves": len(self.move_history),
"winner": self.winner,
}
def get_statistics(self) -> dict[str, Any]:
board = self.game.board.grid
total_cells = self.board_size * self.board_size
occupied = int(np.count_nonzero(board))
coverage = occupied / total_cells
scores = self.game.get_scores()
pieces_placed = []
for i in range(self.num_players):
total = len(self.game.piece_set.pieces)
remaining = len(self.game.players[i].available_pieces)
pieces_placed.append(total - remaining)
largest_piece = []
for i in range(self.num_players):
max_size = 0
for move in self.move_history:
if move["player_idx"] == i:
piece = self.game.piece_set.get_piece(move["piece_id"])
max_size = max(max_size, piece.size)
largest_piece.append(max_size)
if self.game_over:
sorted_scores = sorted(scores, reverse=True)
margin = sorted_scores[0] - sorted_scores[1] if len(sorted_scores) > 1 else 0
else:
margin = 0
monomino_bonus = []
for i in range(self.num_players):
bonus = 0
player_moves = [m for m in self.move_history if m["player_idx"] == i]
if player_moves:
last_move = player_moves[-1]
piece = self.game.piece_set.get_piece(last_move["piece_id"])
if piece.size == 1:
bonus = 5
monomino_bonus.append(bonus)
return {
"coverage": round(coverage * 100, 1),
"pieces_placed": pieces_placed,
"squares_placed": scores,
"largest_piece": largest_piece,
"winner_margin": margin,
"monomino_bonus": monomino_bonus,
"total_moves": len(self.move_history),
}
def to_dict(self) -> dict[str, Any]:
return {
"config": {
"board_size": self.board_size,
"num_players": self.num_players,
"human_player": self.human_player,
"bot_type": self.bot_type_name,
"bot_seed": self.bot_seed,
"minimax_depth": self.minimax_depth,
},
"move_history": self.move_history,
"game_over": self.game_over,
"winner": self.winner,
"scores": self.game.get_scores(),
"statistics": self.get_statistics(),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> GameSession:
config = data["config"]
session = cls.__new__(cls)
session.board_size = config["board_size"]
session.num_players = config["num_players"]
session.human_player = config["human_player"]
session.bot_type_name = config["bot_type"]
session.bot_seed = config["bot_seed"]
session.minimax_depth = config.get("minimax_depth", 2)
session.game = BlokusGame(
board_size=session.board_size,
pieces=PieceSet(STANDARD_PIECES),
num_players=session.num_players,
)
session.game.reset()
session.move_history = []
session.game_over = False
session.winner = None
session.last_bot_move = None
session._init_bots()
for move in data["move_history"]:
action = move["action"]
session._record_move(move["player_idx"], action)
session.game.play_move(move["player_idx"], action)
session.game.next_player()
if data["game_over"]:
session.game_over = True
session.winner = data["winner"]
return session
+434
View File
@@ -0,0 +1,434 @@
document.addEventListener('DOMContentLoaded', function() {
if (document.getElementById('game-container')) {
initGame();
} else if (document.getElementById('replay-container')) {
initReplay();
}
});
function initGame() {
const sessionId = window.GAME_SESSION_ID;
const boardSize = window.BOARD_SIZE;
const cellSize = window.CELL_SIZE || 30;
const cellGap = 1;
let selectedPieceId = null;
let currentPlacements = [];
let gameSaved = false;
async function refresh() {
const response = await fetch(`/api/game/${sessionId}/state`);
const state = await response.json();
renderBoard(state.board, boardSize, cellSize);
renderSidebar(state);
renderStatusBar(state);
if (state.game_over) {
handleGameOver(state);
}
}
function renderBoard(board, size, cs) {
const boardEl = document.getElementById('board');
boardEl.innerHTML = '';
boardEl.style.gridTemplateColumns = `repeat(${size}, ${cs}px)`;
boardEl.style.gridTemplateRows = `repeat(${size}, ${cs}px)`;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const cell = document.createElement('div');
cell.className = `cell player-${board[y][x]}`;
boardEl.appendChild(cell);
}
}
}
function renderSidebar(state) {
const sidebar = document.getElementById('sidebar');
sidebar.innerHTML = '';
const playerInfo = document.createElement('div');
playerInfo.id = 'player-info';
state.players.forEach(function(player) {
const el = document.createElement('div');
el.className = 'player-info';
el.innerHTML =
'<span class="player-name ' + player.color + '">' + player.name + '</span>' +
'<span class="player-meta">' +
'<span class="score">Score: ' + player.score + '</span>' +
'<span class="pieces">' + player.pieces_remaining + '/' + player.total_pieces + '</span>' +
'</span>';
playerInfo.appendChild(el);
});
sidebar.appendChild(playerInfo);
const piecesEl = document.createElement('div');
piecesEl.id = 'pieces';
const piecesTitle = document.createElement('h3');
piecesTitle.textContent = 'Your Pieces';
piecesEl.appendChild(piecesTitle);
if (state.available_pieces.length === 0) {
const emptyMsg = document.createElement('p');
emptyMsg.className = 'empty';
emptyMsg.textContent = 'No pieces remaining';
piecesEl.appendChild(emptyMsg);
} else {
state.available_pieces.forEach(function(piece) {
piecesEl.appendChild(renderPiece(piece, state.players[state.human_player].color));
});
}
sidebar.appendChild(piecesEl);
const controls = document.createElement('div');
controls.id = 'controls';
controls.innerHTML =
'<button id="save-btn">Save Replay</button>' +
'<a href="/">Back to Menu</a>';
sidebar.appendChild(controls);
document.getElementById('save-btn').addEventListener('click', saveReplay);
document.getElementById('status-save-btn').addEventListener('click', saveReplay);
}
function renderPiece(piece, playerColor) {
const container = document.createElement('div');
container.className = 'piece';
container.dataset.pieceId = piece.id;
const width = Math.max.apply(null, piece.squares.map(function(s) { return s[0]; })) + 1;
const height = Math.max.apply(null, piece.squares.map(function(s) { return s[1]; })) + 1;
container.style.gridTemplateColumns = 'repeat(' + width + ', 1fr)';
container.style.gridTemplateRows = 'repeat(' + height + ', 1fr)';
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const cell = document.createElement('div');
cell.className = 'piece-cell';
const isFilled = piece.squares.some(function(s) { return s[0] === x && s[1] === y; });
if (isFilled) {
cell.classList.add('filled');
cell.style.background = getPlayerColor(playerColor);
}
container.appendChild(cell);
}
}
container.addEventListener('click', function() { selectPiece(piece.id); });
return container;
}
function getPlayerColor(name) {
var colors = { red: '#ff6b6b', blue: '#4dabf7', yellow: '#ffd43b', green: '#51cf66' };
return colors[name] || '#ff6b6b';
}
async function selectPiece(pieceId) {
if (selectedPieceId === pieceId) {
selectedPieceId = null;
clearPlacements();
document.querySelectorAll('.piece').forEach(function(p) { p.classList.remove('selected'); });
return;
}
selectedPieceId = pieceId;
document.querySelectorAll('.piece').forEach(function(p) { p.classList.remove('selected'); });
var selectedEl = document.querySelector('.piece[data-piece-id="' + pieceId + '"]');
if (selectedEl) { selectedEl.classList.add('selected'); }
const response = await fetch(`/api/game/${sessionId}/placements?piece_id=${pieceId}`);
const data = await response.json();
currentPlacements = data.placements;
renderPlacements(currentPlacements);
}
function renderPlacements(placements) {
const overlay = document.getElementById('placement-overlay');
overlay.innerHTML = '';
if (placements.length === 0) {
return;
}
placements.forEach(function(placement) {
placement.cells.forEach(function(cell) {
const x = cell[0], y = cell[1];
const el = document.createElement('div');
el.className = 'placement-cell';
el.style.left = (x * (cellSize + cellGap)) + 'px';
el.style.top = (y * (cellSize + cellGap)) + 'px';
el.style.width = cellSize + 'px';
el.style.height = cellSize + 'px';
el.dataset.action = placement.action;
el.addEventListener('click', function() { makeMove(placement.action); });
overlay.appendChild(el);
});
});
}
function clearPlacements() {
currentPlacements = [];
document.getElementById('placement-overlay').innerHTML = '';
}
async function makeMove(action) {
const response = await fetch(`/api/game/${sessionId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action }),
});
const result = await response.json();
if (result.success) {
selectedPieceId = null;
clearPlacements();
document.querySelectorAll('.piece').forEach(function(p) { p.classList.remove('selected'); });
await refresh();
} else {
console.error('Move failed:', result.error);
}
}
async function saveReplay() {
const response = await fetch(`/api/game/${sessionId}/save`, { method: 'POST' });
const result = await response.json();
if (result.success) {
gameSaved = true;
alert('Replay saved!');
} else {
alert('Failed to save: ' + result.error);
}
}
async function autoSave() {
if (gameSaved) return;
try {
const response = await fetch(`/api/game/${sessionId}/save`, { method: 'POST' });
const result = await response.json();
if (result.success) {
gameSaved = true;
const statusBar = document.getElementById('status-bar');
statusBar.textContent += ' | Replay auto-saved!';
}
} catch (e) {
console.error('Auto-save failed:', e);
}
}
function renderStatusBar(state) {
const statusText = document.getElementById('status-text');
const saveBtn = document.getElementById('status-save-btn');
if (state.game_over) {
if (state.winner !== null) {
statusText.textContent = 'Game Over! Winner: ' + state.players[state.winner].name;
} else {
statusText.textContent = 'Game Over! Tie!';
}
saveBtn.style.display = 'none';
} else {
statusText.textContent = 'Current player: ' + state.players[state.current_player].name;
saveBtn.style.display = 'inline-block';
}
}
function handleGameOver(state) {
const statusText = document.getElementById('status-text');
const saveBtn = document.getElementById('status-save-btn');
let text = 'Game Over! ';
if (state.winner !== null) {
text += 'Winner: ' + state.players[state.winner].name;
} else {
text += 'Tie!';
}
text += ' | Scores: ' + state.players.map(function(p) {
return p.name + ': ' + p.score;
}).join(', ');
statusText.textContent = text;
saveBtn.style.display = 'none';
document.querySelectorAll('.piece').forEach(function(p) {
p.style.pointerEvents = 'none';
p.style.opacity = '0.5';
});
autoSave();
}
refresh();
window.addEventListener('beforeunload', function() {
if (!gameSaved) {
navigator.sendBeacon(`/api/game/${sessionId}/save`);
}
});
}
function initReplay() {
const boardSize = window.BOARD_SIZE;
const totalMoves = window.TOTAL_MOVES;
const replayData = window.REPLAY_DATA;
const cellSize = window.CELL_SIZE || 30;
const states = replayData.states;
const moves = replayData.moves;
const config = replayData.config;
const statistics = replayData.statistics;
let currentStep = 0;
let isPlaying = false;
let playInterval = null;
function renderStep(step) {
currentStep = step;
const state = states[step];
renderBoard(state.board, boardSize, cellSize);
renderMoveList(moves, step, config.human_player);
renderStatistics(statistics);
const slider = document.getElementById('step-slider');
slider.value = step;
slider.max = totalMoves;
document.getElementById('step-indicator').textContent = step + ' / ' + totalMoves;
document.getElementById('prev-btn').disabled = step === 0;
document.getElementById('next-btn').disabled = step >= totalMoves;
if (step >= totalMoves && isPlaying) {
stopPlay();
}
}
function renderBoard(board, size, cs) {
const boardEl = document.getElementById('board');
boardEl.innerHTML = '';
boardEl.style.gridTemplateColumns = 'repeat(' + size + ', ' + cs + 'px)';
boardEl.style.gridTemplateRows = 'repeat(' + size + ', ' + cs + 'px)';
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const cell = document.createElement('div');
cell.className = 'cell player-' + board[y][x];
boardEl.appendChild(cell);
}
}
}
function renderMoveList(moves, currentStep, humanPlayer) {
const listEl = document.getElementById('move-list');
listEl.innerHTML = '';
moves.forEach(function(move, index) {
const el = document.createElement('div');
el.className = 'move-entry';
if (index < currentStep) {
el.classList.add('played');
}
if (index === currentStep - 1) {
el.classList.add('current');
}
var playerColor = ['red', 'blue', 'yellow', 'green'][move.player_idx];
var isHuman = move.player_idx === humanPlayer ? ' (you)' : '';
el.innerHTML =
'<span class="move-num">' + (index + 1) + '</span>' +
'<span class="move-player ' + playerColor + '">P' + (move.player_idx + 1) + isHuman + '</span>' +
'<span class="move-piece">' + move.piece_name + '</span>' +
'<span class="move-pos">(' + move.x + ', ' + move.y + ')</span>';
listEl.appendChild(el);
});
}
function renderStatistics(stats) {
const statsEl = document.getElementById('statistics');
statsEl.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Statistics';
statsEl.appendChild(title);
var rows = [
['Coverage', stats.coverage + '%'],
['Total Moves', stats.total_moves]
];
stats.squares_placed.forEach(function(score, i) {
rows.push(['P' + (i + 1) + ' Score', score]);
});
stats.pieces_placed.forEach(function(placed, i) {
rows.push(['P' + (i + 1) + ' Pieces', placed]);
});
rows.push(['Winner Margin', stats.winner_margin]);
rows.forEach(function(row) {
var r = document.createElement('div');
r.className = 'stat-row';
r.innerHTML = '<span>' + row[0] + '</span><span>' + row[1] + '</span>';
statsEl.appendChild(r);
});
}
function togglePlay() {
if (isPlaying) {
stopPlay();
} else {
startPlay();
}
}
function startPlay() {
if (currentStep >= totalMoves) {
currentStep = 0;
}
isPlaying = true;
document.getElementById('play-btn').textContent = '\u23F8 Pause';
var speed = parseInt(document.getElementById('speed-select').value);
playInterval = setInterval(function() {
if (currentStep < totalMoves) {
renderStep(currentStep + 1);
} else {
stopPlay();
}
}, speed);
}
function stopPlay() {
isPlaying = false;
document.getElementById('play-btn').textContent = '\u25B6 Play';
if (playInterval) {
clearInterval(playInterval);
playInterval = null;
}
}
document.getElementById('prev-btn').addEventListener('click', function() {
if (currentStep > 0) {
renderStep(currentStep - 1);
}
});
document.getElementById('next-btn').addEventListener('click', function() {
if (currentStep < totalMoves) {
renderStep(currentStep + 1);
}
});
document.getElementById('play-btn').addEventListener('click', togglePlay);
document.getElementById('step-slider').addEventListener('input', function(e) {
var step = parseInt(e.target.value);
if (isPlaying) {
stopPlay();
}
renderStep(step);
});
renderStep(0);
}
+496
View File
@@ -0,0 +1,496 @@
:root {
--bg: #0f0e17;
--panel: #1a1a2e;
--panel-2: #16213e;
--border: #2a2a4a;
--text: #e0e0e0;
--text-muted: #888;
--red: #ff6b6b;
--blue: #4dabf7;
--yellow: #ffd43b;
--green: #51cf66;
--accent: #ffcc00;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
a {
color: var(--blue);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
font-size: 2em;
margin-bottom: 20px;
color: var(--accent);
}
h2 {
font-size: 1.3em;
margin-bottom: 12px;
color: var(--text);
}
h3 {
font-size: 1em;
margin-bottom: 8px;
color: var(--text-muted);
}
/* ===== Home Page ===== */
.home {
max-width: 900px;
margin: 0 auto;
}
.section {
background: var(--panel);
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
}
#new-game-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: end;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 0.85em;
color: var(--text-muted);
margin-bottom: 4px;
}
.form-group select,
.form-group input {
padding: 8px 10px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.95em;
}
#new-game-form button {
grid-column: 1 / -1;
padding: 12px;
background: var(--blue);
color: var(--bg);
border: none;
border-radius: 6px;
font-size: 1.1em;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
}
#new-game-form button:hover {
background: #5bb3ff;
}
.runs-table {
width: 100%;
border-collapse: collapse;
}
.runs-table th,
.runs-table td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 0.9em;
}
.runs-table th {
color: var(--text-muted);
font-weight: normal;
text-transform: uppercase;
font-size: 0.75em;
letter-spacing: 0.5px;
}
.runs-table tr:hover {
background: var(--panel-2);
}
.delete-btn {
background: none;
border: none;
color: #ff6b6b;
font-size: 1.2em;
cursor: pointer;
padding: 0 4px;
}
.delete-btn:hover {
color: #ff8888;
}
.empty {
color: var(--text-muted);
text-align: center;
padding: 30px;
}
/* ===== Game Page ===== */
#game-container {
display: flex;
gap: 20px;
align-items: flex-start;
}
#board-container {
position: relative;
}
#board {
display: grid;
gap: 1px;
background: #333;
border: 2px solid var(--border);
border-radius: 4px;
}
.cell {
width: 30px;
height: 30px;
background: #2a2a2a;
transition: background 0.2s;
}
.cell.player-0 { background: #2a2a2a; }
.cell.player-1 { background: var(--red); }
.cell.player-2 { background: var(--blue); }
.cell.player-3 { background: var(--yellow); }
.cell.player-4 { background: var(--green); }
#placement-overlay {
position: absolute;
top: 2px;
left: 2px;
width: calc(100% - 4px);
height: calc(100% - 4px);
pointer-events: none;
}
.placement-cell {
position: absolute;
width: 30px;
height: 30px;
background: rgba(255, 204, 0, 0.25);
border: 1px dashed rgba(255, 204, 0, 0.7);
pointer-events: auto;
cursor: pointer;
box-sizing: border-box;
border-radius: 2px;
}
.placement-cell:hover {
background: rgba(255, 204, 0, 0.45);
}
#sidebar {
width: 260px;
background: var(--panel);
border-radius: 10px;
padding: 15px;
overflow-y: auto;
max-height: 80vh;
}
#player-info {
margin-bottom: 15px;
}
.player-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.player-info:last-child {
border-bottom: none;
}
.player-name {
font-weight: bold;
font-size: 0.95em;
}
.player-name.red { color: var(--red); }
.player-name.blue { color: var(--blue); }
.player-name.yellow { color: var(--yellow); }
.player-name.green { color: var(--green); }
.player-meta {
display: flex;
gap: 10px;
font-size: 0.85em;
color: var(--text-muted);
}
.player-meta .score {
color: var(--text);
}
#pieces {
margin-bottom: 15px;
}
#pieces h3 {
margin-bottom: 10px;
}
.piece {
display: inline-grid;
gap: 1px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
padding: 3px;
margin-bottom: 5px;
transition: all 0.2s;
}
.piece:hover {
border-color: var(--accent);
transform: scale(1.05);
}
.piece.selected {
border: 2px solid var(--accent);
background: var(--panel-2);
}
.piece-cell {
width: 16px;
height: 16px;
background: transparent;
}
.piece-cell.filled {
background: var(--red);
}
#controls {
margin-top: 15px;
display: flex;
flex-direction: column;
gap: 8px;
}
#controls button,
#controls a {
display: block;
width: 100%;
padding: 10px;
text-align: center;
background: var(--panel-2);
color: var(--text);
text-decoration: none;
border-radius: 6px;
border: 1px solid var(--border);
cursor: pointer;
font-size: 0.95em;
transition: background 0.2s;
}
#controls button:hover,
#controls a:hover {
background: var(--blue);
color: var(--bg);
}
#status-bar {
margin-top: 20px;
padding: 12px 16px;
background: var(--panel);
border-radius: 8px;
font-weight: bold;
font-size: 1.05em;
min-height: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
#status-save-btn {
padding: 4px 12px;
background: var(--blue);
color: var(--bg);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
font-weight: bold;
}
#status-save-btn:hover {
background: #5bb3ff;
}
/* ===== Replay Page ===== */
#replay-container {
display: flex;
gap: 20px;
align-items: flex-start;
}
#replay-sidebar {
width: 320px;
}
#replay-controls {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
#replay-controls button {
padding: 6px 14px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
transition: background 0.2s;
}
#replay-controls button:hover:not(:disabled) {
background: var(--blue);
color: var(--bg);
}
#replay-controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#step-slider {
width: 100%;
margin: 10px 0;
accent-color: var(--blue);
}
.speed-control {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 15px;
}
.speed-control label {
font-size: 0.85em;
color: var(--text-muted);
}
.speed-control select {
padding: 4px 8px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.9em;
}
#move-list {
max-height: 280px;
overflow-y: auto;
margin-bottom: 15px;
background: var(--panel);
border-radius: 8px;
padding: 8px;
}
.move-entry {
display: flex;
gap: 8px;
align-items: center;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.85em;
transition: background 0.2s;
}
.move-entry.played {
color: var(--text-muted);
}
.move-entry.current {
background: var(--panel-2);
color: var(--text);
font-weight: bold;
}
.move-num {
color: var(--text-muted);
width: 24px;
}
.move-player.red { color: var(--red); }
.move-player.blue { color: var(--blue); }
.move-player.yellow { color: var(--yellow); }
.move-player.green { color: var(--green); }
#statistics {
background: var(--panel);
border-radius: 8px;
padding: 15px;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px solid var(--border);
font-size: 0.9em;
}
.stat-row:last-child {
border-bottom: none;
}
.stat-row span:first-child {
color: var(--text-muted);
}
.stat-row span:last-child {
color: var(--text);
font-weight: bold;
}
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
import json
import os
import uuid
from datetime import datetime
from typing import Any
class RunStore:
"""File-based storage for game replays as JSON files.
Each saved run is a self-contained JSON file with game configuration,
move history, and computed statistics.
"""
def __init__(self, runs_dir: str | None = None):
if runs_dir is None:
runs_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "runs"
)
self.runs_dir = os.path.abspath(runs_dir)
os.makedirs(self.runs_dir, exist_ok=True)
def save(self, session, name: str | None = None) -> str:
"""Save a GameSession as a replay file. Returns the run_id."""
data = session.to_dict()
if name is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
bot_type = data["config"]["bot_type"]
name = f"{bot_type}_{timestamp}"
run_id = f"{name}_{uuid.uuid4().hex[:8]}"
filepath = os.path.join(self.runs_dir, f"{run_id}.json")
run_data = {
"run_id": run_id,
"name": name,
"created_at": datetime.now().isoformat(),
"data": data,
}
with open(filepath, "w") as f:
json.dump(run_data, f, indent=2)
return run_id
def list_runs(self) -> list[dict[str, Any]]:
"""List all saved runs with metadata, sorted by date (newest first)."""
runs = []
for filename in os.listdir(self.runs_dir):
if not filename.endswith(".json"):
continue
filepath = os.path.join(self.runs_dir, filename)
try:
with open(filepath) as f:
run_data = json.load(f)
config = run_data["data"]["config"]
stats = run_data["data"].get("statistics", {})
scores = run_data["data"].get("scores", [])
runs.append(
{
"run_id": run_data["run_id"],
"name": run_data["name"],
"created_at": run_data["created_at"],
"board_size": config["board_size"],
"num_players": config["num_players"],
"human_player": config["human_player"],
"bot_type": config["bot_type"],
"game_over": run_data["data"]["game_over"],
"winner": run_data["data"]["winner"],
"scores": scores,
"total_moves": stats.get("total_moves", 0),
"coverage": stats.get("coverage", 0),
}
)
except (json.JSONDecodeError, KeyError):
continue
runs.sort(key=lambda r: r["created_at"], reverse=True)
return runs
def load(self, run_id: str) -> dict[str, Any] | None:
"""Load a run by ID. Returns the full run data dict, or None."""
filepath = os.path.join(self.runs_dir, f"{run_id}.json")
if not os.path.exists(filepath):
return None
with open(filepath) as f:
return json.load(f)
def delete(self, run_id: str) -> bool:
"""Delete a run by ID. Returns True if deleted, False if not found."""
filepath = os.path.join(self.runs_dir, f"{run_id}.json")
if os.path.exists(filepath):
os.remove(filepath)
return True
return False
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Blokus{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
<script src="/static/game.js"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Blokus — Game{% endblock %}
{% block content %}
<div id="game-container">
<div id="board-container">
<div id="board"></div>
<div id="placement-overlay"></div>
</div>
<div id="sidebar">
<div id="player-info"></div>
<div id="pieces"></div>
<div id="controls">
<button id="save-btn">Save Replay</button>
<a href="/">Back to Menu</a>
</div>
</div>
</div>
<div id="status-bar">
<span id="status-text">Loading...</span>
<button id="status-save-btn" style="display:none;">Save Now</button>
</div>
<script>
window.GAME_SESSION_ID = "{{ session_id }}";
window.BOARD_SIZE = {{ board_size }};
window.CELL_SIZE = 30;
</script>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Blokus — Home{% endblock %}
{% block content %}
<div class="home">
<h1>Blokus</h1>
<div class="section">
<h2>New Game</h2>
<form id="new-game-form" action="/game/new" method="POST">
<div class="form-group">
<label>Board Size</label>
<select name="board_size">
<option value="14">14×14 (Duo)</option>
<option value="20" selected>20×20 (Standard)</option>
</select>
</div>
<div class="form-group">
<label>Players</label>
<select name="num_players" id="num_players_select">
<option value="2">2 Players</option>
<option value="4" selected>4 Players</option>
</select>
</div>
<div class="form-group">
<label>Your Seat</label>
<select name="human_player" id="human_player_select">
<option value="0" selected>Player 1 (Red)</option>
<option value="1">Player 2 (Blue)</option>
<option value="2">Player 3 (Yellow)</option>
<option value="3">Player 4 (Green)</option>
</select>
</div>
<div class="form-group">
<label>Opponent Bot</label>
<select name="bot_type">
<option value="random">Random (casual)</option>
<option value="greedy" selected>Greedy (moderate)</option>
<option value="greedy_corners">Greedy Corners (strong)</option>
<option value="minimax">Minimax (2P only)</option>
</select>
</div>
<div class="form-group">
<label>Random Seed</label>
<input type="number" name="bot_seed" value="42" min="0">
</div>
<button type="submit">Start Game</button>
</form>
</div>
<div class="section">
<h2>Previous Runs</h2>
{% if runs %}
<table class="runs-table">
<thead>
<tr>
<th>Date</th>
<th>Board</th>
<th>Players</th>
<th>Bot</th>
<th>Result</th>
<th>Moves</th>
<th>Coverage</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for run in runs %}
<tr>
<td>{{ run.created_at[:19] }}</td>
<td>{{ run.board_size }}×{{ run.board_size }}</td>
<td>{{ run.num_players }}P</td>
<td>{{ run.bot_type }}</td>
<td>
{% if run.game_over %}
{% if run.winner is not none %}
Winner: P{{ run.winner + 1 }}
{% else %}
Tie
{% endif %}
{% else %}
In progress
{% endif %}
</td>
<td>{{ run.total_moves }}</td>
<td>{{ run.coverage }}%</td>
<td>
<a href="/replay/{{ run.run_id }}">Replay</a>
<form method="POST" action="/runs/{{ run.run_id }}/delete" style="display:inline;">
<button type="submit" class="delete-btn" title="Delete">×</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">No previous runs. Start a new game!</p>
{% endif %}
</div>
</div>
<script>
const numPlayersSelect = document.getElementById('num_players_select');
const humanPlayerSelect = document.getElementById('human_player_select');
function updateHumanOptions() {
const numPlayers = parseInt(numPlayersSelect.value);
humanPlayerSelect.innerHTML = '';
const names = ['Player 1 (Red)', 'Player 2 (Blue)', 'Player 3 (Yellow)', 'Player 4 (Green)'];
for (let i = 0; i < numPlayers; i++) {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = names[i];
humanPlayerSelect.appendChild(opt);
}
}
numPlayersSelect.addEventListener('change', updateHumanOptions);
updateHumanOptions();
</script>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Blokus — Replay{% endblock %}
{% block content %}
<div id="replay-container">
<div id="board-container">
<div id="board"></div>
</div>
<div id="replay-sidebar">
<div id="replay-controls">
<button id="prev-btn">◀ Prev</button>
<button id="play-btn">▶ Play</button>
<button id="next-btn">Next ▶</button>
<span id="step-indicator">0 / {{ total_moves }}</span>
</div>
<input type="range" id="step-slider" min="0" max="{{ total_moves }}" value="0">
<div class="speed-control">
<label>Speed:</label>
<select id="speed-select">
<option value="800">0.5x</option>
<option value="400" selected>1x</option>
<option value="200">2x</option>
<option value="100">4x</option>
</select>
</div>
<div id="move-list"></div>
<div id="statistics"></div>
<div style="margin-top: 15px;">
<a href="/runs">← Back to Runs</a>
</div>
</div>
</div>
<script>
window.REPLAY_RUN_ID = "{{ run_id }}";
window.BOARD_SIZE = {{ board_size }};
window.TOTAL_MOVES = {{ total_moves }};
window.REPLAY_DATA = {{ replay_data | tojson }};
window.CELL_SIZE = 30;
</script>
{% endblock %}
+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()],
)
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import os
import tempfile
from blokus_ui.session import GameSession
from blokus_ui.store import RunStore
def make_session(**kwargs):
"""Create a GameSession with sensible defaults for testing."""
defaults = {
"board_size": 7,
"num_players": 2,
"human_player": 0,
"bot_type": "random",
"bot_seed": 42,
}
defaults.update(kwargs)
return GameSession(**defaults)
class TestGameSession:
def test_creation(self):
session = make_session()
assert session.board_size == 7
assert session.num_players == 2
assert session.human_player == 0
assert session.bot_type_name == "random"
def test_initial_state(self):
session = make_session()
state = session.get_state()
assert state["board_size"] == 7
assert state["human_player"] == 0
assert state["game_over"] is False
assert len(state["players"]) == 2
assert len(state["available_pieces"]) > 0
def test_valid_placements(self):
session = make_session()
placements = session.get_valid_placements(0)
assert len(placements) > 0
for p in placements:
assert "action" in p
assert "cells" in p
assert "x" in p
assert "y" in p
def test_apply_human_action(self):
session = make_session()
placements = session.get_valid_placements(0)
action = placements[0]["action"]
result = session.apply_human_action(action)
assert result["success"] is True
def test_invalid_action(self):
session = make_session()
result = session.apply_human_action(999999)
assert result["success"] is False
def test_move_history(self):
session = make_session()
placements = session.get_valid_placements(0)
action = placements[0]["action"]
session.apply_human_action(action)
assert len(session.move_history) > 0
move = session.move_history[0]
assert move["player_idx"] == 0
assert "piece_name" in move
assert "x" in move
assert "y" in move
def test_game_over_detection(self):
session = make_session(board_size=7)
steps = 0
while not session.game_over and steps < 200:
state = session.get_state()
moved = False
for piece in state["available_pieces"]:
placements = session.get_valid_placements(piece["id"])
if placements:
session.apply_human_action(placements[0]["action"])
moved = True
break
if not moved:
break
steps += 1
assert session.game_over is True
def test_serialization(self):
session = make_session()
placements = session.get_valid_placements(0)
session.apply_human_action(placements[0]["action"])
data = session.to_dict()
assert "config" in data
assert "move_history" in data
assert "game_over" in data
assert data["config"]["board_size"] == 7
assert data["config"]["num_players"] == 2
def test_deserialization(self):
session = make_session()
placements = session.get_valid_placements(0)
session.apply_human_action(placements[0]["action"])
data = session.to_dict()
restored = GameSession.from_dict(data)
assert restored.board_size == session.board_size
assert restored.num_players == session.num_players
assert len(restored.move_history) == len(session.move_history)
assert restored.game_over == session.game_over
def test_statistics(self):
session = make_session()
placements = session.get_valid_placements(0)
session.apply_human_action(placements[0]["action"])
stats = session.get_statistics()
assert "coverage" in stats
assert "pieces_placed" in stats
assert "squares_placed" in stats
assert "total_moves" in stats
assert len(stats["pieces_placed"]) == 2
assert len(stats["squares_placed"]) == 2
def test_four_player_game(self):
session = make_session(num_players=4)
state = session.get_state()
assert len(state["players"]) == 4
def test_greedy_bot(self):
session = make_session(bot_type="greedy")
placements = session.get_valid_placements(0)
assert len(placements) > 0
def test_minimax_bot(self):
session = make_session(bot_type="minimax")
placements = session.get_valid_placements(0)
assert len(placements) > 0
class TestRunStore:
def test_save_and_load(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
session = make_session()
placements = session.get_valid_placements(0)
session.apply_human_action(placements[0]["action"])
run_id = store.save(session, name="test_run")
assert run_id is not None
run_data = store.load(run_id)
assert run_data is not None
assert run_data["name"] == "test_run"
assert run_data["data"]["config"]["board_size"] == 7
def test_list_runs(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
session = make_session()
store.save(session, name="run1")
session2 = make_session(bot_type="greedy", bot_seed=43)
store.save(session2, name="run2")
runs = store.list_runs()
assert len(runs) == 2
assert runs[0]["name"] in ("run1", "run2")
assert runs[0]["board_size"] == 7
def test_delete_run(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
session = make_session()
run_id = store.save(session, name="test_run")
assert store.delete(run_id) is True
assert store.load(run_id) is None
assert store.delete(run_id) is False
def test_load_nonexistent(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
assert store.load("nonexistent") is None
def test_list_empty(self):
with tempfile.TemporaryDirectory() as tmpdir:
store = RunStore(runs_dir=tmpdir)
assert store.list_runs() == []
def test_runs_dir_creation(self):
with tempfile.TemporaryDirectory() as tmpdir:
runs_dir = os.path.join(tmpdir, "new_dir")
RunStore(runs_dir=runs_dir)
assert os.path.exists(runs_dir)