Compare commits
10
Commits
4eb34bfeeb
...
0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
946196f2eb | ||
|
|
37cc621020 | ||
|
|
c14a98fbca | ||
|
|
3657a820ba | ||
|
|
3b2ffa28d4 | ||
|
|
10d8b0a376 | ||
|
|
ba6e26ab59 | ||
|
|
4a4eef679a | ||
|
|
70672e9fef | ||
|
|
123513e32f |
+13
@@ -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
@@ -5,12 +5,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
[project]
|
[project]
|
||||||
name = "blokus-gym"
|
name = "blokus-gym"
|
||||||
version = "0.1.0"
|
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"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
authors = [{name = "Blokus Gym Contributors"}]
|
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 = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
"Intended Audience :: Science/Research",
|
"Intended Audience :: Science/Research",
|
||||||
@@ -29,6 +29,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
render = ["matplotlib>=3.7"]
|
render = ["matplotlib>=3.7"]
|
||||||
|
ui = ["flask>=3.0"]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.4",
|
"pytest>=7.4",
|
||||||
"pytest-cov>=4.1",
|
"pytest-cov>=4.1",
|
||||||
@@ -45,6 +46,9 @@ train = [
|
|||||||
Homepage = "https://github.com/blokus-gym/blokus-gym"
|
Homepage = "https://github.com/blokus-gym/blokus-gym"
|
||||||
Documentation = "https://github.com/blokus-gym/blokus-gym#readme"
|
Documentation = "https://github.com/blokus-gym/blokus-gym#readme"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
blokus-ui = "blokus_ui.app:app"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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 @@
|
|||||||
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
blokus_gym
|
|
||||||
@@ -1,15 +1,32 @@
|
|||||||
from blokus_gym.core.board import Board
|
# Try to import Cython version first, fall back to Python
|
||||||
from blokus_gym.core.bots import Bot, GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
|
try:
|
||||||
from blokus_gym.core.game import BlokusGame, Move
|
from .blokus_cython import (
|
||||||
from blokus_gym.core.pieces import (
|
Board,
|
||||||
DUO_PIECES,
|
BlokusGame,
|
||||||
JUNIOR_PIECES,
|
Move,
|
||||||
STANDARD_PIECES,
|
Piece,
|
||||||
Piece,
|
PieceOrientation,
|
||||||
PieceOrientation,
|
PieceSet,
|
||||||
PieceSet,
|
)
|
||||||
generate_orientations,
|
# 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,
|
||||||
|
Piece,
|
||||||
|
PieceOrientation,
|
||||||
|
PieceSet,
|
||||||
|
generate_orientations,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Board",
|
"Board",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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,11 +16,15 @@ class Board:
|
|||||||
def __init__(self, size: int):
|
def __init__(self, size: int):
|
||||||
self.size = size
|
self.size = size
|
||||||
self.grid = np.zeros((size, size), dtype=np.int8)
|
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:
|
def place(self, player_idx: int, squares: list[tuple[int, int]]) -> None:
|
||||||
"""Place a player's piece on the board."""
|
"""Place a player's piece on the board."""
|
||||||
for x, y in squares:
|
for x, y in squares:
|
||||||
self.grid[y, x] = player_idx
|
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:
|
def clear(self) -> None:
|
||||||
"""Reset the board to empty."""
|
"""Reset the board to empty."""
|
||||||
@@ -45,12 +49,20 @@ class Board:
|
|||||||
"""Get the player index at a cell (0 = empty)."""
|
"""Get the player index at a cell (0 = empty)."""
|
||||||
return self.grid[y, x]
|
return self.grid[y, x]
|
||||||
|
|
||||||
def get_player_squares(self, player_idx: int) -> list[tuple[int, int]]:
|
def get_player_squares(self, player_idx: int, player_board_idx: int | None = None) -> list[tuple[int, int]]:
|
||||||
"""Get all squares occupied by a player."""
|
"""Get all squares occupied by a player."""
|
||||||
ys, xs = np.where(self.grid == player_idx)
|
if player_board_idx is None:
|
||||||
|
player_board_idx = player_idx + 1 # Default: PLAYER_OFFSET = 1
|
||||||
|
ys, xs = np.where(self.grid == player_board_idx)
|
||||||
return [(int(x), int(y)) for x, y in zip(xs, ys, strict=True)]
|
return [(int(x), int(y)) for x, y in zip(xs, ys, strict=True)]
|
||||||
|
|
||||||
def get_player_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.
|
"""Get all corner cells adjacent to a player's pieces.
|
||||||
|
|
||||||
Corner cells are the diagonal neighbors of a player's pieces.
|
Corner cells are the diagonal neighbors of a player's pieces.
|
||||||
@@ -58,7 +70,7 @@ class Board:
|
|||||||
(corner-to-corner contact rule).
|
(corner-to-corner contact rule).
|
||||||
"""
|
"""
|
||||||
corners: set[tuple[int, int]] = set()
|
corners: set[tuple[int, int]] = set()
|
||||||
player_squares = self.get_player_squares(player_idx)
|
player_squares = self.get_player_squares(player_board_idx - 1, player_board_idx)
|
||||||
for x, y in player_squares:
|
for x, y in player_squares:
|
||||||
for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
|
for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
|
||||||
cx, cy = x + dx, y + dy
|
cx, cy = x + dx, y + dy
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ from abc import ABC, abstractmethod
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from blokus_gym.core.game import BlokusGame
|
try:
|
||||||
|
from blokus_gym.core.blokus_cython import BlokusGame
|
||||||
|
except ImportError:
|
||||||
|
from blokus_gym.core.game import BlokusGame
|
||||||
|
|
||||||
|
|
||||||
class Bot(ABC):
|
class Bot(ABC):
|
||||||
@@ -76,7 +79,7 @@ class GreedyCornersBot(Bot):
|
|||||||
for action in valid_indices:
|
for action in valid_indices:
|
||||||
move = game.get_move(int(action))
|
move = game.get_move(int(action))
|
||||||
piece = game.piece_set.get_piece(move.piece_id)
|
piece = game.piece_set.get_piece(move.piece_id)
|
||||||
placed_corners = game._get_placed_corners(move)
|
placed_corners = game.get_placed_corners(move)
|
||||||
|
|
||||||
# Count how many new corners this move opens
|
# Count how many new corners this move opens
|
||||||
new_corners = 0
|
new_corners = 0
|
||||||
|
|||||||
src/blokus_gym/core/build/lib.linux-x86_64-cpython-312/blokus_cython.cpython-312-x86_64-linux-gnu.so
Executable
BIN
Binary file not shown.
Binary file not shown.
+75
-20
@@ -55,6 +55,14 @@ class BlokusGame:
|
|||||||
self._move_to_action: dict[tuple[int, int, int, int], int] = {}
|
self._move_to_action: dict[tuple[int, int, int, int], int] = {}
|
||||||
self._generate_action_space()
|
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)
|
# Starting corners for each player (standard Blokus layout)
|
||||||
# 4 players: all four corners
|
# 4 players: all four corners
|
||||||
# 2 players: opposite corners (0,0) and (max, max)
|
# 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)
|
# Rule 4: Corner rule (must touch same-color at a corner)
|
||||||
if player.has_started:
|
if player.has_started:
|
||||||
# Must touch at least one same-color piece at a corner
|
|
||||||
placed_corners = self._get_placed_corners(move)
|
placed_corners = self._get_placed_corners(move)
|
||||||
touches_corner = False
|
# Use cached occupied squares from board for fast lookup
|
||||||
for cx, cy in placed_corners:
|
player_occupied = self.board.get_player_occupied(player_board_idx)
|
||||||
if self.board.in_bounds(cx, cy):
|
touches_corner = any(
|
||||||
if self.board.get_cell(cx, cy) == player_board_idx:
|
self.board.in_bounds(cx, cy) and (cx, cy) in player_occupied
|
||||||
touches_corner = True
|
for cx, cy in placed_corners
|
||||||
break
|
)
|
||||||
if not touches_corner:
|
if not touches_corner:
|
||||||
return False
|
return False
|
||||||
# If not started yet, first move doesn't need to touch (it's the first piece)
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -219,13 +225,59 @@ class BlokusGame:
|
|||||||
if not player.can_move:
|
if not player.can_move:
|
||||||
return mask
|
return mask
|
||||||
|
|
||||||
for action_idx in range(self.num_actions):
|
available_piece_ids = [
|
||||||
move = self._action_moves[action_idx]
|
self.piece_set.get_piece_id(name)
|
||||||
piece = self.piece_set.get_piece(move.piece_id)
|
for name in player.available_pieces
|
||||||
if piece.name not in player.available_pieces:
|
]
|
||||||
continue
|
|
||||||
if self.valid_move(player_idx, move):
|
if not player.has_started or not player.corners:
|
||||||
mask[action_idx] = True
|
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]
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
return mask
|
||||||
|
|
||||||
@@ -240,11 +292,14 @@ class BlokusGame:
|
|||||||
if not player.can_move:
|
if not player.can_move:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for action_idx in range(self.num_actions):
|
available_piece_ids = [
|
||||||
move = self._action_moves[action_idx]
|
self.piece_set.get_piece_id(name)
|
||||||
piece = self.piece_set.get_piece(move.piece_id)
|
for name in player.available_pieces
|
||||||
if piece.name in player.available_pieces:
|
]
|
||||||
if self.valid_move(player_idx, move):
|
|
||||||
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -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()],
|
||||||
|
)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
from blokus_ui.session import GameSession
|
||||||
|
from blokus_ui.store import RunStore
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GameSession",
|
||||||
|
"RunStore",
|
||||||
|
"__version__",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
src/build/lib.linux-x86_64-cpython-312/blokus_gym/core/blokus_cython.cpython-312-x86_64-linux-gnu.so
Executable
BIN
Binary file not shown.
Binary file not shown.
@@ -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()],
|
||||||
|
)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
Reference in New Issue
Block a user