initial commit
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
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"
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
blokus_gym
|
||||
@@ -0,0 +1,39 @@
|
||||
from blokus_gym.core.board import Board
|
||||
from blokus_gym.core.bots import Bot, GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
|
||||
from blokus_gym.core.game import BlokusGame, Move
|
||||
from blokus_gym.core.pieces import (
|
||||
DUO_PIECES,
|
||||
JUNIOR_PIECES,
|
||||
STANDARD_PIECES,
|
||||
Piece,
|
||||
PieceOrientation,
|
||||
PieceSet,
|
||||
generate_orientations,
|
||||
)
|
||||
from blokus_gym.envs.blokus_env import BlokusEnv
|
||||
from blokus_gym.envs.multiagent import BlokusMultiAgentEnv
|
||||
from blokus_gym.wrappers.action_mask import ActionMaskWrapper
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"BlokusEnv",
|
||||
"BlokusMultiAgentEnv",
|
||||
"ActionMaskWrapper",
|
||||
"Board",
|
||||
"BlokusGame",
|
||||
"Move",
|
||||
"Piece",
|
||||
"PieceOrientation",
|
||||
"PieceSet",
|
||||
"STANDARD_PIECES",
|
||||
"DUO_PIECES",
|
||||
"JUNIOR_PIECES",
|
||||
"generate_orientations",
|
||||
"Bot",
|
||||
"RandomBot",
|
||||
"GreedyBot",
|
||||
"GreedyCornersBot",
|
||||
"MinimaxBot",
|
||||
"__version__",
|
||||
]
|
||||
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
from blokus_gym.core.board import Board
|
||||
from blokus_gym.core.bots import Bot, GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
|
||||
from blokus_gym.core.game import BlokusGame, Move
|
||||
from blokus_gym.core.pieces import (
|
||||
DUO_PIECES,
|
||||
JUNIOR_PIECES,
|
||||
STANDARD_PIECES,
|
||||
Piece,
|
||||
PieceOrientation,
|
||||
PieceSet,
|
||||
generate_orientations,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Board",
|
||||
"BlokusGame",
|
||||
"Move",
|
||||
"Piece",
|
||||
"PieceOrientation",
|
||||
"PieceSet",
|
||||
"STANDARD_PIECES",
|
||||
"DUO_PIECES",
|
||||
"JUNIOR_PIECES",
|
||||
"generate_orientations",
|
||||
"Bot",
|
||||
"RandomBot",
|
||||
"GreedyBot",
|
||||
"GreedyCornersBot",
|
||||
"MinimaxBot",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class Board:
|
||||
"""A square game board represented as a numpy array.
|
||||
|
||||
Cell values:
|
||||
0 = empty
|
||||
1-4 = player index (1-based for consistency with Blokus colors)
|
||||
"""
|
||||
|
||||
EMPTY = 0
|
||||
|
||||
def __init__(self, size: int):
|
||||
self.size = size
|
||||
self.grid = np.zeros((size, size), dtype=np.int8)
|
||||
|
||||
def place(self, player_idx: int, squares: list[tuple[int, int]]) -> None:
|
||||
"""Place a player's piece on the board."""
|
||||
for x, y in squares:
|
||||
self.grid[y, x] = player_idx
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Reset the board to empty."""
|
||||
self.grid.fill(0)
|
||||
|
||||
def is_empty(self, x: int, y: int) -> bool:
|
||||
"""Check if a cell is empty."""
|
||||
return self.grid[y, x] == 0
|
||||
|
||||
def in_bounds(self, x: int, y: int) -> bool:
|
||||
"""Check if coordinates are within the board."""
|
||||
return 0 <= x < self.size and 0 <= y < self.size
|
||||
|
||||
def has_overlap(self, squares: list[tuple[int, int]]) -> bool:
|
||||
"""Check if any square in the list is already occupied."""
|
||||
for x, y in squares:
|
||||
if not self.in_bounds(x, y) or self.grid[y, x] != 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_cell(self, x: int, y: int) -> int:
|
||||
"""Get the player index at a cell (0 = empty)."""
|
||||
return self.grid[y, x]
|
||||
|
||||
def get_player_squares(self, player_idx: int) -> list[tuple[int, int]]:
|
||||
"""Get all squares occupied by a player."""
|
||||
ys, xs = np.where(self.grid == player_idx)
|
||||
return [(int(x), int(y)) for x, y in zip(xs, ys, strict=True)]
|
||||
|
||||
def get_player_corners(self, player_idx: int) -> set[tuple[int, int]]:
|
||||
"""Get all corner cells adjacent to a player's pieces.
|
||||
|
||||
Corner cells are the diagonal neighbors of a player's pieces.
|
||||
These are the cells where the player can place new pieces
|
||||
(corner-to-corner contact rule).
|
||||
"""
|
||||
corners: set[tuple[int, int]] = set()
|
||||
player_squares = self.get_player_squares(player_idx)
|
||||
for x, y in player_squares:
|
||||
for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
|
||||
cx, cy = x + dx, y + dy
|
||||
if self.in_bounds(cx, cy) and self.grid[cy, cx] == 0:
|
||||
corners.add((cx, cy))
|
||||
return corners
|
||||
|
||||
def get_occupied(self) -> set[tuple[int, int]]:
|
||||
"""Get all occupied cells."""
|
||||
ys, xs = np.where(self.grid != 0)
|
||||
return {(int(x), int(y)) for x, y in zip(xs, ys, strict=True)}
|
||||
|
||||
def is_full(self) -> bool:
|
||||
"""Check if the board is completely full."""
|
||||
return np.all(self.grid != 0)
|
||||
|
||||
def coverage(self) -> float:
|
||||
"""Get the fraction of the board that is occupied."""
|
||||
return float(np.count_nonzero(self.grid)) / (self.size * self.size)
|
||||
|
||||
def copy(self) -> Board:
|
||||
"""Create a deep copy of the board."""
|
||||
new_board = Board(self.size)
|
||||
new_board.grid = self.grid.copy()
|
||||
return new_board
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Board(size={self.size}, coverage={self.coverage():.2%})"
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import numpy as np
|
||||
|
||||
from blokus_gym.core.game import BlokusGame
|
||||
|
||||
|
||||
class Bot(ABC):
|
||||
"""Base class for Blokus bots."""
|
||||
|
||||
def __init__(self, player_idx: int, seed: int | None = None):
|
||||
self.player_idx = player_idx
|
||||
self.rng = random.Random(seed)
|
||||
|
||||
@abstractmethod
|
||||
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
|
||||
"""Select an action from the valid actions.
|
||||
|
||||
Args:
|
||||
game: The current game state.
|
||||
valid_actions: Boolean mask of valid actions.
|
||||
|
||||
Returns:
|
||||
Action index, or None if no valid moves.
|
||||
"""
|
||||
...
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(player={self.player_idx})"
|
||||
|
||||
|
||||
class RandomBot(Bot):
|
||||
"""Randomly selects from valid actions."""
|
||||
|
||||
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
|
||||
valid_indices = np.where(valid_actions)[0]
|
||||
if len(valid_indices) == 0:
|
||||
return None
|
||||
return int(self.rng.choice(valid_indices))
|
||||
|
||||
|
||||
class GreedyBot(Bot):
|
||||
"""Selects the move that places the most squares (largest piece)."""
|
||||
|
||||
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
|
||||
valid_indices = np.where(valid_actions)[0]
|
||||
if len(valid_indices) == 0:
|
||||
return None
|
||||
|
||||
best_action = -1
|
||||
best_size = -1
|
||||
for action in valid_indices:
|
||||
move = game.get_move(int(action))
|
||||
piece = game.piece_set.get_piece(move.piece_id)
|
||||
if piece.size > best_size:
|
||||
best_size = piece.size
|
||||
best_action = int(action)
|
||||
|
||||
return best_action
|
||||
|
||||
|
||||
class GreedyCornersBot(Bot):
|
||||
"""Greedy bot that prefers moves opening more corners for future play."""
|
||||
|
||||
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
|
||||
valid_indices = np.where(valid_actions)[0]
|
||||
if len(valid_indices) == 0:
|
||||
return None
|
||||
|
||||
best_action = -1
|
||||
best_score = float("-inf")
|
||||
|
||||
for action in valid_indices:
|
||||
move = game.get_move(int(action))
|
||||
piece = game.piece_set.get_piece(move.piece_id)
|
||||
placed_corners = game._get_placed_corners(move)
|
||||
|
||||
# Count how many new corners this move opens
|
||||
new_corners = 0
|
||||
for cx, cy in placed_corners:
|
||||
if game.board.in_bounds(cx, cy) and game.board.is_empty(cx, cy):
|
||||
new_corners += 1
|
||||
|
||||
# Score = piece size + corner bonus
|
||||
score = piece.size + new_corners * 0.5
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_action = int(action)
|
||||
|
||||
return best_action
|
||||
|
||||
|
||||
class MinimaxBot(Bot):
|
||||
"""Minimax search bot (primarily for 2-player games).
|
||||
|
||||
Uses a depth-limited minimax search with a simple heuristic.
|
||||
"""
|
||||
|
||||
def __init__(self, player_idx: int, depth: int = 2, seed: int | None = None):
|
||||
super().__init__(player_idx, seed)
|
||||
self.depth = depth
|
||||
|
||||
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
|
||||
valid_indices = np.where(valid_actions)[0]
|
||||
if len(valid_indices) == 0:
|
||||
return None
|
||||
|
||||
if len(valid_indices) == 1:
|
||||
return int(valid_indices[0])
|
||||
|
||||
best_action = -1
|
||||
best_value = float("-inf")
|
||||
|
||||
for action in valid_indices:
|
||||
if game.num_players != 2:
|
||||
# For multi-player, just use greedy
|
||||
return GreedyBot(self.player_idx, self.rng.randint(0, 2**31)).select_action(
|
||||
game, valid_actions
|
||||
)
|
||||
|
||||
cloned = game.copy()
|
||||
success = cloned.play_move(self.player_idx, int(action))
|
||||
if not success:
|
||||
continue
|
||||
|
||||
value = self._minimax(cloned, self.depth - 1, False)
|
||||
if value > best_value:
|
||||
best_value = value
|
||||
best_action = int(action)
|
||||
|
||||
return best_action if best_action >= 0 else int(valid_indices[0])
|
||||
|
||||
def _minimax(self, game: BlokusGame, depth: int, maximizing: bool) -> float:
|
||||
if depth == 0 or game.is_game_over():
|
||||
return self._evaluate(game)
|
||||
|
||||
if maximizing:
|
||||
value = float("-inf")
|
||||
valid = game.get_valid_actions(self.player_idx)
|
||||
valid_indices = np.where(valid)[0]
|
||||
for action in valid_indices:
|
||||
cloned = game.copy()
|
||||
if cloned.play_move(self.player_idx, int(action)):
|
||||
val = self._minimax(cloned, depth - 1, False)
|
||||
value = max(value, val)
|
||||
return value
|
||||
else:
|
||||
# Opponent's turn
|
||||
opp_idx = 1 - self.player_idx
|
||||
value = float("inf")
|
||||
valid = game.get_valid_actions(opp_idx)
|
||||
valid_indices = np.where(valid)[0]
|
||||
for action in valid_indices:
|
||||
cloned = game.copy()
|
||||
if cloned.play_move(opp_idx, int(action)):
|
||||
val = self._minimax(cloned, depth - 1, True)
|
||||
value = min(value, val)
|
||||
return value
|
||||
|
||||
def _evaluate(self, game: BlokusGame) -> float:
|
||||
"""Simple heuristic: difference in placed squares."""
|
||||
my_score = game.players[self.player_idx].score
|
||||
opp_idx = 1 - self.player_idx
|
||||
opp_score = game.players[opp_idx].score if opp_idx < len(game.players) else 0
|
||||
return float(my_score - opp_score)
|
||||
@@ -0,0 +1,414 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
|
||||
from blokus_gym.core.board import Board
|
||||
from blokus_gym.core.pieces import STANDARD_PIECES, Move, PieceSet
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerState:
|
||||
"""Tracks the state of a single player in the game."""
|
||||
|
||||
idx: int # 0-based player index
|
||||
available_pieces: set[str] = field(default_factory=set) # Piece names still available
|
||||
score: int = 0 # Squares placed (positive = good)
|
||||
corners: set[tuple[int, int]] = field(default_factory=set) # Valid placement corners
|
||||
has_started: bool = False # Whether player has placed their first piece
|
||||
can_move: bool = True # Whether player can still make any move
|
||||
|
||||
|
||||
class BlokusGame:
|
||||
"""Core Blokus game logic.
|
||||
|
||||
Manages the board state, player turns, move validation, and scoring.
|
||||
This class is independent of the Gymnasium environment and can be
|
||||
used standalone for game simulation or bot development.
|
||||
"""
|
||||
|
||||
# Player indices are 1-based for board representation
|
||||
# (0 = empty, 1 = player 1, etc.)
|
||||
PLAYER_OFFSET = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
board_size: int = 20,
|
||||
pieces: PieceSet | None = None,
|
||||
num_players: int = 4,
|
||||
corner_rule: bool = 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: list[PlayerState] = []
|
||||
self.current_player = 0
|
||||
self.rounds = 0
|
||||
self.game_over = False
|
||||
|
||||
# Pre-compute all possible moves (action lookup table)
|
||||
self._action_moves: list[Move] = []
|
||||
self._move_to_action: dict[tuple[int, int, int, int], int] = {}
|
||||
self._generate_action_space()
|
||||
|
||||
# Starting corners for each player (standard Blokus layout)
|
||||
# 4 players: all four corners
|
||||
# 2 players: opposite corners (0,0) and (max, max)
|
||||
# 3 players: three 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)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action space generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _generate_action_space(self) -> None:
|
||||
"""Pre-compute all possible (piece, orientation, position) moves.
|
||||
|
||||
Each unique combination is assigned a stable integer action index.
|
||||
"""
|
||||
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
|
||||
# Compute bounding box of the piece
|
||||
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=piece_id,
|
||||
orientation_id=orient_id,
|
||||
x=px,
|
||||
y=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, action: int) -> Move:
|
||||
"""Get the Move object for a given action index."""
|
||||
return self._action_moves[action]
|
||||
|
||||
def get_action(self, move: Move) -> int:
|
||||
"""Get the action index for a given Move."""
|
||||
return self._move_to_action[(move.piece_id, move.orientation_id, move.x, move.y)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Game initialization
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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(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()
|
||||
self.players.append(player)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Move validation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_placed_squares(self, move: Move) -> list[tuple[int, int]]:
|
||||
"""Get the absolute board coordinates for a move."""
|
||||
orient = self.piece_set.get_orientations(move.piece_id)[move.orientation_id]
|
||||
return [(move.x + dx, move.y + dy) for dx, dy in orient.squares]
|
||||
|
||||
def _get_placed_corners(self, move: Move) -> list[tuple[int, int]]:
|
||||
"""Get the absolute corner coordinates for a move."""
|
||||
orient = self.piece_set.get_orientations(move.piece_id)[move.orientation_id]
|
||||
return [(move.x + dx, move.y + dy) for dx, dy in orient.corners]
|
||||
|
||||
def valid_move(self, player_idx: int, move: Move) -> bool:
|
||||
"""Check if a move is valid for the given player.
|
||||
|
||||
Validation rules:
|
||||
1. Player must have the piece available
|
||||
2. All squares must be in bounds
|
||||
3. No overlap with existing pieces
|
||||
4. Corner rule: piece must touch same-color piece at a corner
|
||||
5. No edge adjacency with same-color pieces
|
||||
6. First move must be in a corner (if corner_rule is enabled)
|
||||
"""
|
||||
player = self.players[player_idx]
|
||||
piece = self.piece_set.get_piece(move.piece_id)
|
||||
|
||||
# Rule 1: Check if player has the piece
|
||||
if piece.name not in player.available_pieces:
|
||||
return False
|
||||
|
||||
placed_squares = self._get_placed_squares(move)
|
||||
|
||||
# Rule 2: Check bounds
|
||||
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
|
||||
|
||||
player_board_idx = player_idx + self.PLAYER_OFFSET
|
||||
|
||||
# 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
|
||||
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):
|
||||
if self.board.get_cell(nx, ny) == player_board_idx:
|
||||
return False
|
||||
|
||||
# Rule 4: Corner rule (must touch same-color at a corner)
|
||||
if player.has_started:
|
||||
# Must touch at least one same-color piece at a corner
|
||||
placed_corners = self._get_placed_corners(move)
|
||||
touches_corner = False
|
||||
for cx, cy in placed_corners:
|
||||
if self.board.in_bounds(cx, cy):
|
||||
if self.board.get_cell(cx, cy) == player_board_idx:
|
||||
touches_corner = True
|
||||
break
|
||||
if not touches_corner:
|
||||
return False
|
||||
# If not started yet, first move doesn't need to touch (it's the first piece)
|
||||
|
||||
return True
|
||||
|
||||
def get_valid_actions(self, player_idx: int) -> np.ndarray:
|
||||
"""Get a boolean mask of valid actions for the given player."""
|
||||
mask = np.zeros(self.num_actions, dtype=bool)
|
||||
player = self.players[player_idx]
|
||||
|
||||
if not player.can_move:
|
||||
return mask
|
||||
|
||||
for action_idx in range(self.num_actions):
|
||||
move = self._action_moves[action_idx]
|
||||
piece = self.piece_set.get_piece(move.piece_id)
|
||||
if piece.name not in player.available_pieces:
|
||||
continue
|
||||
if self.valid_move(player_idx, move):
|
||||
mask[action_idx] = True
|
||||
|
||||
return mask
|
||||
|
||||
def get_valid_action_indices(self, player_idx: int) -> list[int]:
|
||||
"""Get a list of valid action indices for the given player."""
|
||||
mask = self.get_valid_actions(player_idx)
|
||||
return [int(i) for i in np.where(mask)[0]]
|
||||
|
||||
def has_valid_moves(self, player_idx: int) -> bool:
|
||||
"""Check if a player has any valid moves."""
|
||||
player = self.players[player_idx]
|
||||
if not player.can_move:
|
||||
return False
|
||||
|
||||
for action_idx in range(self.num_actions):
|
||||
move = self._action_moves[action_idx]
|
||||
piece = self.piece_set.get_piece(move.piece_id)
|
||||
if piece.name in player.available_pieces:
|
||||
if self.valid_move(player_idx, move):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Move execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def apply_move(self, player_idx: int, move: Move) -> None:
|
||||
"""Apply a move to the game state. Does not validate."""
|
||||
player = self.players[player_idx]
|
||||
piece = self.piece_set.get_piece(move.piece_id)
|
||||
placed_squares = self._get_placed_squares(move)
|
||||
placed_corners = self._get_placed_corners(move)
|
||||
|
||||
# Place on board
|
||||
player_board_idx = player_idx + self.PLAYER_OFFSET
|
||||
self.board.place(player_board_idx, placed_squares)
|
||||
|
||||
# Update player state
|
||||
player.score += piece.size
|
||||
player.available_pieces.discard(piece.name)
|
||||
player.has_started = True
|
||||
|
||||
# Update corners: remove covered squares, add new corners
|
||||
for cx, cy in placed_corners:
|
||||
if self.board.in_bounds(cx, cy) and self.board.is_empty(cx, cy):
|
||||
player.corners.add((cx, cy))
|
||||
|
||||
# Remove corners that are now occupied
|
||||
player.corners = {
|
||||
(x, y) for x, y in player.corners if self.board.is_empty(x, y)
|
||||
}
|
||||
|
||||
def play_move(self, player_idx: int, action: int) -> bool:
|
||||
"""Validate and apply a move. Returns True if successful."""
|
||||
move = self.get_move(action)
|
||||
if not self.valid_move(player_idx, move):
|
||||
return False
|
||||
self.apply_move(player_idx, move)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Turn management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def next_player(self) -> int:
|
||||
"""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 advance_turn(self) -> None:
|
||||
"""Apply a move for the current player, then advance.
|
||||
|
||||
This is a convenience method for bot-driven games.
|
||||
"""
|
||||
self.next_player()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Game state queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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
|
||||
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[int]:
|
||||
"""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.
|
||||
"""
|
||||
scores = []
|
||||
for player in self.players:
|
||||
# Sum sizes of remaining pieces
|
||||
unplaced = sum(
|
||||
self.piece_set.pieces[pid].size
|
||||
for pid, piece in enumerate(self.piece_set.pieces)
|
||||
if piece.name in player.available_pieces
|
||||
)
|
||||
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[int] | None:
|
||||
"""Get the winning player(s). Returns None if game is not over."""
|
||||
if not self.is_game_over():
|
||||
return None
|
||||
|
||||
scores = self.get_scores()
|
||||
max_score = max(scores)
|
||||
winners = [i for i, s in enumerate(scores) if s == max_score]
|
||||
return winners
|
||||
|
||||
def get_current_observation(self) -> dict:
|
||||
"""Get the current game state as an observation dict."""
|
||||
player_idx = self.current_player
|
||||
player = self.players[player_idx]
|
||||
|
||||
# Board as player indices (1-based)
|
||||
board_obs = self.board.grid.copy()
|
||||
|
||||
# Available pieces as binary vector
|
||||
pieces_obs = np.zeros(self.piece_set.num_pieces, dtype=bool)
|
||||
for name in player.available_pieces:
|
||||
pieces_obs[self.piece_set.get_piece_id(name)] = True
|
||||
|
||||
# Corners as boolean grid
|
||||
corners_obs = np.zeros((self.board_size, self.board_size), dtype=bool)
|
||||
for x, y in player.corners:
|
||||
if self.board.in_bounds(x, y):
|
||||
corners_obs[y, x] = True
|
||||
|
||||
return {
|
||||
"board": board_obs.astype(np.int8),
|
||||
"pieces": pieces_obs,
|
||||
"corners": corners_obs,
|
||||
}
|
||||
|
||||
def get_action_mask(self, player_idx: int) -> np.ndarray:
|
||||
"""Get the action mask for a player."""
|
||||
return self.get_valid_actions(player_idx)
|
||||
|
||||
def copy(self) -> BlokusGame:
|
||||
"""Create a deep copy of the game state."""
|
||||
import copy
|
||||
|
||||
new_game = BlokusGame.__new__(BlokusGame)
|
||||
new_game.board_size = self.board_size
|
||||
new_game.piece_set = self.piece_set
|
||||
new_game.num_players = self.num_players
|
||||
new_game.corner_rule = self.corner_rule
|
||||
new_game.board = self.board.copy()
|
||||
new_game.players = copy.deepcopy(self.players)
|
||||
new_game.current_player = self.current_player
|
||||
new_game.rounds = self.rounds
|
||||
new_game.game_over = self.game_over
|
||||
new_game._action_moves = self._action_moves
|
||||
new_game._move_to_action = self._move_to_action
|
||||
new_game._starting_corners = self._starting_corners
|
||||
return new_game
|
||||
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Piece:
|
||||
"""A polyomino piece defined by a set of (x, y) coordinates.
|
||||
|
||||
Coordinates are relative to a reference point (typically the
|
||||
top-left corner of the bounding box after normalization).
|
||||
"""
|
||||
|
||||
name: str
|
||||
squares: frozenset[tuple[int, int]]
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self.squares)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"squares": sorted(self.squares),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> Piece:
|
||||
return cls(
|
||||
name=data["name"],
|
||||
squares=frozenset(tuple(s) for s in data["squares"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrientedPiece:
|
||||
"""A piece in a specific orientation placed at a specific position."""
|
||||
|
||||
piece_id: int
|
||||
orientation_id: int
|
||||
squares: list[tuple[int, int]]
|
||||
corners: list[tuple[int, int]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Move:
|
||||
"""A complete move: which piece, which orientation, where placed."""
|
||||
|
||||
piece_id: int
|
||||
orientation_id: int
|
||||
x: int
|
||||
y: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class PieceOrientation:
|
||||
"""A piece in a specific orientation (before placement on board)."""
|
||||
|
||||
piece_id: int
|
||||
orientation_id: int
|
||||
squares: list[tuple[int, int]] # normalized relative coordinates
|
||||
corners: list[tuple[int, int]] # corner cells relative to squares
|
||||
|
||||
|
||||
def _normalize(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
"""Shift coordinates so the minimum x and y are 0."""
|
||||
min_x = min(x for x, _ in squares)
|
||||
min_y = min(y for _, y in squares)
|
||||
return [(x - min_x, y - min_y) for x, y in squares]
|
||||
|
||||
|
||||
def _rotate(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
"""Rotate 90 degrees clockwise: (x, y) -> (y, -x)."""
|
||||
return [(y, -x) for x, y in squares]
|
||||
|
||||
|
||||
def _flip(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
"""Flip horizontally: (x, y) -> (-x, y)."""
|
||||
return [(-x, y) for x, y in squares]
|
||||
|
||||
|
||||
def _compute_corners(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
||||
"""Compute the corner cells for a piece.
|
||||
|
||||
Corner cells are the diagonal neighbors of each square that are not
|
||||
edge-adjacent to any other square in the piece. These are the cells
|
||||
where a same-color piece must touch (corner-to-corner).
|
||||
"""
|
||||
square_set = set(squares)
|
||||
corners = set()
|
||||
for x, y in squares:
|
||||
for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
|
||||
cx, cy = x + dx, y + dy
|
||||
if (cx, cy) not in square_set:
|
||||
# Check that this corner is not edge-adjacent to another square
|
||||
edge_neighbors = [
|
||||
(cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)
|
||||
]
|
||||
if not any(en in square_set for en in edge_neighbors):
|
||||
corners.add((cx, cy))
|
||||
return sorted(corners)
|
||||
|
||||
|
||||
def generate_orientations(piece: Piece) -> list[PieceOrientation]:
|
||||
"""Generate all unique orientations of a piece.
|
||||
|
||||
Applies 4 rotations and 2 flips, then deduplicates by comparing
|
||||
normalized coordinate sets.
|
||||
"""
|
||||
orientations: list[PieceOrientation] = []
|
||||
seen: set[tuple[tuple[int, int], ...]] = set()
|
||||
|
||||
squares = sorted(piece.squares)
|
||||
for flip_count in range(2):
|
||||
for rotation_count in range(4):
|
||||
current = list(squares)
|
||||
for _ in range(rotation_count):
|
||||
current = _rotate(current)
|
||||
for _ in range(flip_count):
|
||||
current = _flip(current)
|
||||
normalized = tuple(sorted(_normalize(current)))
|
||||
if normalized not in seen:
|
||||
seen.add(normalized)
|
||||
corners = _compute_corners(list(normalized))
|
||||
orientations.append(
|
||||
PieceOrientation(
|
||||
piece_id=-1, # Set by PieceSet
|
||||
orientation_id=len(orientations),
|
||||
squares=list(normalized),
|
||||
corners=corners,
|
||||
)
|
||||
)
|
||||
return orientations
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standard Blokus piece sets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The 21 free polyominoes of size 1-5
|
||||
# Naming follows standard pentomino/tetromino conventions
|
||||
|
||||
_I1 = Piece("I1", frozenset([(0, 0)]))
|
||||
|
||||
_I2 = Piece("I2", frozenset([(0, 0), (0, 1)]))
|
||||
|
||||
_I3 = Piece("I3", frozenset([(0, 0), (0, 1), (0, 2)]))
|
||||
|
||||
_V3 = Piece("V3", frozenset([(0, 0), (1, 0), (0, 1)]))
|
||||
|
||||
_I4 = Piece("I4", frozenset([(0, 0), (0, 1), (0, 2), (0, 3)]))
|
||||
|
||||
_L4 = Piece("L4", frozenset([(0, 0), (0, 1), (0, 2), (1, 0)]))
|
||||
|
||||
_T4 = Piece("T4", frozenset([(0, 0), (1, 0), (2, 0), (1, 1)]))
|
||||
|
||||
_S4 = Piece("S4", frozenset([(0, 0), (1, 0), (1, 1), (2, 1)]))
|
||||
|
||||
_O4 = Piece("O4", frozenset([(0, 0), (1, 0), (0, 1), (1, 1)]))
|
||||
|
||||
_I5 = Piece("I5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)]))
|
||||
|
||||
_L5 = Piece("L5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 0)]))
|
||||
|
||||
_Y5 = Piece("Y5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 1)]))
|
||||
|
||||
_N5 = Piece("N5", frozenset([(0, 0), (1, 0), (2, 0), (2, 1), (3, 1)]))
|
||||
|
||||
_T5 = Piece("T5", frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, 2)]))
|
||||
|
||||
_U5 = Piece("U5", frozenset([(0, 0), (2, 0), (0, 1), (1, 1), (2, 1)]))
|
||||
|
||||
_V5 = Piece("V5", frozenset([(0, 0), (0, 1), (0, 2), (1, 0), (2, 0)]))
|
||||
|
||||
_W5 = Piece("W5", frozenset([(0, 0), (0, 1), (1, 0), (1, 1), (2, 0)]))
|
||||
|
||||
_Z5 = Piece("Z5", frozenset([(0, 0), (1, 0), (1, 1), (1, 2), (2, 2)]))
|
||||
|
||||
_F5 = Piece("F5", frozenset([(0, 0), (1, 0), (1, 1), (2, 1), (1, 2)]))
|
||||
|
||||
_X5 = Piece("X5", frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, -1)]))
|
||||
|
||||
_P5 = Piece("P5", frozenset([(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)]))
|
||||
|
||||
STANDARD_PIECES: list[Piece] = [
|
||||
_I1, _I2, _I3, _V3, _I4, _L4, _T4, _S4, _O4,
|
||||
_I5, _L5, _Y5, _N5, _T5, _U5, _V5, _W5, _Z5, _F5, _X5, _P5,
|
||||
]
|
||||
|
||||
# Blokus Duo uses the same pieces but on a 14x14 board
|
||||
DUO_PIECES: list[Piece] = STANDARD_PIECES
|
||||
|
||||
# Blokus Junior: simplified pieces (only 12 unique pieces, 2 copies each)
|
||||
# Uses only pieces with size <= 4 for simplicity
|
||||
JUNIOR_PIECES: list[Piece] = [
|
||||
_I1, _I2, _I3, _V3, _I4, _L4, _T4, _S4, _O4,
|
||||
]
|
||||
|
||||
|
||||
class PieceSet:
|
||||
"""A collection of pieces with pre-computed orientations.
|
||||
|
||||
This class manages the piece set used in a Blokus game, including
|
||||
all unique orientations for each piece and a lookup table for
|
||||
generating moves.
|
||||
"""
|
||||
|
||||
def __init__(self, pieces: list[Piece]):
|
||||
self.pieces: list[Piece] = pieces
|
||||
self.piece_names: list[str] = [p.name for p in pieces]
|
||||
self.piece_id_map: dict[str, int] = {name: i for i, name in enumerate(self.piece_names)}
|
||||
|
||||
# Pre-compute all orientations for each piece
|
||||
self.orientations: list[list[PieceOrientation]] = []
|
||||
for piece_id, piece in enumerate(pieces):
|
||||
orients = generate_orientations(piece)
|
||||
for orient in orients:
|
||||
orient.piece_id = piece_id
|
||||
self.orientations.append(orients)
|
||||
|
||||
# Total number of (piece, orientation) combinations
|
||||
self.num_orientations: int = sum(len(o) for o in self.orientations)
|
||||
|
||||
@property
|
||||
def num_pieces(self) -> int:
|
||||
return len(self.pieces)
|
||||
|
||||
def get_orientations(self, piece_id: int) -> list[PieceOrientation]:
|
||||
return self.orientations[piece_id]
|
||||
|
||||
def get_piece(self, piece_id: int) -> Piece:
|
||||
return self.pieces[piece_id]
|
||||
|
||||
def get_piece_id(self, name: str) -> int:
|
||||
return self.piece_id_map[name]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"pieces": [p.to_dict() for p in self.pieces],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> PieceSet:
|
||||
pieces = [Piece.from_dict(p) for p in data["pieces"]]
|
||||
return cls(pieces)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, path: str) -> PieceSet:
|
||||
import json
|
||||
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
return cls.from_dict(data)
|
||||
|
||||
def save_json(self, path: str) -> None:
|
||||
import json
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.pieces)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"PieceSet(num_pieces={self.num_pieces}, num_orientations={self.num_orientations})"
|
||||
@@ -0,0 +1,97 @@
|
||||
from blokus_gym.core.bots import GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
|
||||
from blokus_gym.core.pieces import DUO_PIECES, JUNIOR_PIECES, STANDARD_PIECES
|
||||
from blokus_gym.envs.blokus_env import BlokusEnv
|
||||
from blokus_gym.envs.multiagent import BlokusMultiAgentEnv
|
||||
|
||||
__all__ = [
|
||||
"BlokusEnv",
|
||||
"BlokusMultiAgentEnv",
|
||||
"STANDARD_PIECES",
|
||||
"DUO_PIECES",
|
||||
"JUNIOR_PIECES",
|
||||
"RandomBot",
|
||||
"GreedyBot",
|
||||
"GreedyCornersBot",
|
||||
"MinimaxBot",
|
||||
]
|
||||
|
||||
|
||||
def _register_envs():
|
||||
"""Register all predefined environment configurations."""
|
||||
import gymnasium as gym
|
||||
|
||||
# Standard 4-player Blokus (20x20)
|
||||
gym.register(
|
||||
id="Blokus-v0",
|
||||
entry_point="blokus_gym.envs:BlokusEnv",
|
||||
kwargs={
|
||||
"num_players": 4,
|
||||
"board_size": 20,
|
||||
"pieces": STANDARD_PIECES,
|
||||
},
|
||||
max_episode_steps=500,
|
||||
)
|
||||
|
||||
# Blokus Duo (2 players, 14x14)
|
||||
gym.register(
|
||||
id="BlokusDuo-v0",
|
||||
entry_point="blokus_gym.envs:BlokusEnv",
|
||||
kwargs={
|
||||
"num_players": 2,
|
||||
"board_size": 14,
|
||||
"pieces": DUO_PIECES,
|
||||
},
|
||||
max_episode_steps=300,
|
||||
)
|
||||
|
||||
# Blokus Junior (2 players, 14x14, simplified pieces)
|
||||
gym.register(
|
||||
id="BlokusJunior-v0",
|
||||
entry_point="blokus_gym.envs:BlokusEnv",
|
||||
kwargs={
|
||||
"num_players": 2,
|
||||
"board_size": 14,
|
||||
"pieces": JUNIOR_PIECES,
|
||||
},
|
||||
max_episode_steps=200,
|
||||
)
|
||||
|
||||
# Simple test env (2 players, 7x7, only small pieces)
|
||||
gym.register(
|
||||
id="BlokusSimple-v0",
|
||||
entry_point="blokus_gym.envs:BlokusEnv",
|
||||
kwargs={
|
||||
"num_players": 2,
|
||||
"board_size": 7,
|
||||
"pieces": [p for p in STANDARD_PIECES if p.size < 5],
|
||||
},
|
||||
max_episode_steps=100,
|
||||
)
|
||||
|
||||
# Greedy bot variants
|
||||
gym.register(
|
||||
id="BlokusGreedy-v0",
|
||||
entry_point="blokus_gym.envs:BlokusEnv",
|
||||
kwargs={
|
||||
"num_players": 4,
|
||||
"board_size": 20,
|
||||
"pieces": STANDARD_PIECES,
|
||||
"bot_type": GreedyBot,
|
||||
},
|
||||
max_episode_steps=500,
|
||||
)
|
||||
|
||||
gym.register(
|
||||
id="BlokusDuoGreedy-v0",
|
||||
entry_point="blokus_gym.envs:BlokusEnv",
|
||||
kwargs={
|
||||
"num_players": 2,
|
||||
"board_size": 14,
|
||||
"pieces": DUO_PIECES,
|
||||
"bot_type": GreedyBot,
|
||||
},
|
||||
max_episode_steps=300,
|
||||
)
|
||||
|
||||
|
||||
_register_envs()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,366 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
from gymnasium import spaces
|
||||
|
||||
from blokus_gym.core.bots import Bot, RandomBot
|
||||
from blokus_gym.core.game import BlokusGame
|
||||
from blokus_gym.core.pieces import STANDARD_PIECES, Piece, PieceSet
|
||||
from blokus_gym.utils.render import render_ansi_board, render_text_board
|
||||
|
||||
|
||||
class BlokusEnv(gym.Env):
|
||||
"""Gymnasium environment for the board game Blokus.
|
||||
|
||||
Supports 2-4 players on customizable board sizes with custom piece sets.
|
||||
In single-agent mode, the agent controls player 0 and opponents are bots.
|
||||
|
||||
Action Space: Discrete(N) where N is the total number of possible
|
||||
(piece, orientation, position) combinations. An action mask is provided
|
||||
in the info dict to indicate valid actions.
|
||||
|
||||
Observation Space: Dict with:
|
||||
- "board": (board_size, board_size) int8 array (0=empty, 1-4=player)
|
||||
- "pieces": MultiBinary(num_pieces) - 1 if piece still available
|
||||
- "corners": (board_size, board_size) bool array - valid corner cells
|
||||
|
||||
Example:
|
||||
>>> import gymnasium as gym
|
||||
>>> from blokus_gym import BlokusEnv
|
||||
>>> env = BlokusEnv(num_players=4, board_size=20)
|
||||
>>> obs, info = env.reset(seed=42)
|
||||
>>> action = env.action_space.sample() # Use info["action_mask"] instead
|
||||
>>> obs, reward, terminated, truncated, info = env.step(action)
|
||||
"""
|
||||
|
||||
metadata = {"render_modes": ["human", "ansi", "rgb_array"]}
|
||||
|
||||
# Player colors for rendering (index 0 = empty)
|
||||
PLAYER_COLORS = ["empty", "red", "blue", "yellow", "green"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_players: int = 4,
|
||||
board_size: int = 20,
|
||||
pieces: list[Piece] | None = None,
|
||||
render_mode: str | None = None,
|
||||
bot_type: type[Bot] = RandomBot,
|
||||
bot_strength: int = 1,
|
||||
reward_shaping: bool = False,
|
||||
corner_rule: bool = True,
|
||||
max_steps: int | None = None,
|
||||
seed: int | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
# Validate parameters
|
||||
assert 2 <= num_players <= 4, f"num_players must be 2-4, got {num_players}"
|
||||
assert board_size >= 5, f"board_size must be >= 5, got {board_size}"
|
||||
|
||||
self.num_players = num_players
|
||||
self.board_size = board_size
|
||||
self.render_mode = render_mode
|
||||
self.bot_type = bot_type
|
||||
self.bot_strength = bot_strength
|
||||
self.reward_shaping = reward_shaping
|
||||
self.corner_rule = corner_rule
|
||||
self.max_steps = max_steps
|
||||
self._seed = seed
|
||||
|
||||
# Initialize piece set
|
||||
self.piece_set = PieceSet(pieces or STANDARD_PIECES)
|
||||
|
||||
# Initialize game
|
||||
self.game = BlokusGame(
|
||||
board_size=board_size,
|
||||
pieces=self.piece_set,
|
||||
num_players=num_players,
|
||||
corner_rule=corner_rule,
|
||||
)
|
||||
|
||||
# Define spaces
|
||||
self.observation_space = spaces.Dict({
|
||||
"board": spaces.Box(0, num_players, (board_size, board_size), dtype=np.int8),
|
||||
"pieces": spaces.MultiBinary(self.piece_set.num_pieces),
|
||||
"corners": spaces.Box(0, 1, (board_size, board_size), dtype=bool),
|
||||
})
|
||||
|
||||
self.action_space = spaces.Discrete(self.game.num_actions)
|
||||
|
||||
# Bot instances (created during reset)
|
||||
self.bots: list[Bot | None] = [None] * num_players
|
||||
|
||||
# Episode tracking
|
||||
self.current_step = 0
|
||||
self._last_observation: dict | None = None
|
||||
|
||||
def _get_obs(self) -> dict:
|
||||
"""Get the current observation from the agent's perspective (player 0)."""
|
||||
obs = self.game.get_current_observation()
|
||||
return {
|
||||
"board": obs["board"],
|
||||
"pieces": obs["pieces"],
|
||||
"corners": obs["corners"],
|
||||
}
|
||||
|
||||
def _get_info(self) -> dict:
|
||||
"""Get auxiliary information."""
|
||||
return {
|
||||
"action_mask": self.game.get_action_mask(0),
|
||||
"current_player": self.game.current_player,
|
||||
"players_with_moves": [
|
||||
i for i in range(self.num_players)
|
||||
if self.game.players[i].can_move and self.game.has_valid_moves(i)
|
||||
],
|
||||
"step_count": self.current_step,
|
||||
"scores": self.game.get_scores(),
|
||||
}
|
||||
|
||||
def reset(
|
||||
self,
|
||||
seed: int | None = None,
|
||||
options: dict | None = None,
|
||||
) -> tuple[dict, dict]:
|
||||
"""Reset the environment to a new episode.
|
||||
|
||||
Args:
|
||||
seed: Random seed for reproducibility.
|
||||
options: Additional options (unused).
|
||||
|
||||
Returns:
|
||||
Tuple of (observation, info).
|
||||
"""
|
||||
super().reset(seed=seed)
|
||||
|
||||
# Re-initialize game
|
||||
self.game = BlokusGame(
|
||||
board_size=self.board_size,
|
||||
pieces=self.piece_set,
|
||||
num_players=self.num_players,
|
||||
corner_rule=self.corner_rule,
|
||||
)
|
||||
self.game.reset()
|
||||
|
||||
# Create bots for opponents
|
||||
bot_seed = seed or 0
|
||||
for i in range(1, self.num_players):
|
||||
self.bots[i] = self.bot_type(
|
||||
player_idx=i,
|
||||
seed=bot_seed + i * 1000,
|
||||
)
|
||||
|
||||
self.current_step = 0
|
||||
|
||||
# Let bots play until it's the agent's turn
|
||||
# The agent (player 0) starts first if corner_rule is enabled
|
||||
self._play_bots_until_agent()
|
||||
|
||||
obs = self._get_obs()
|
||||
info = self._get_info()
|
||||
self._last_observation = obs
|
||||
|
||||
return obs, info
|
||||
|
||||
def _play_bots_until_agent(self) -> None:
|
||||
"""Let bot players take their turns until it's the agent's turn."""
|
||||
max_bot_turns = self.num_players * 10 # Safety limit
|
||||
turns = 0
|
||||
|
||||
while self.game.current_player != 0 and not self.game.is_game_over():
|
||||
if turns > max_bot_turns:
|
||||
break
|
||||
bot = self.bots[self.game.current_player]
|
||||
if bot is None:
|
||||
# Shouldn't happen, but safety
|
||||
self.game.next_player()
|
||||
turns += 1
|
||||
continue
|
||||
|
||||
valid_actions = self.game.get_valid_actions(self.game.current_player)
|
||||
if not np.any(valid_actions):
|
||||
self.game.players[self.game.current_player].can_move = False
|
||||
self.game.next_player()
|
||||
turns += 1
|
||||
continue
|
||||
|
||||
action = bot.select_action(self.game, valid_actions)
|
||||
if action is not None:
|
||||
self.game.play_move(self.game.current_player, action)
|
||||
else:
|
||||
self.game.players[self.game.current_player].can_move = False
|
||||
|
||||
self.game.next_player()
|
||||
turns += 1
|
||||
|
||||
def step(self, action: int) -> tuple[dict, float, bool, bool, dict]:
|
||||
"""Execute one step in the environment.
|
||||
|
||||
The agent (player 0) takes an action, then all bots play until
|
||||
it's the agent's turn again.
|
||||
|
||||
Args:
|
||||
action: Action index (piece, orientation, position).
|
||||
|
||||
Returns:
|
||||
Tuple of (observation, reward, terminated, truncated, info).
|
||||
"""
|
||||
self.current_step += 1
|
||||
|
||||
# Apply agent's action
|
||||
success = self.game.play_move(0, action)
|
||||
|
||||
if not success:
|
||||
# Invalid action - penalize and end episode
|
||||
reward = -10.0
|
||||
obs = self._get_obs()
|
||||
info = self._get_info()
|
||||
info["invalid_action"] = True
|
||||
self._last_observation = obs
|
||||
return obs, reward, True, False, info
|
||||
|
||||
# Advance to next player
|
||||
self.game.next_player()
|
||||
|
||||
# Let bots play until it's the agent's turn or game is over
|
||||
self._play_bots_until_agent()
|
||||
|
||||
# Check if game is over
|
||||
terminated = self.game.is_game_over()
|
||||
truncated = False
|
||||
if self.max_steps is not None and self.current_step >= self.max_steps:
|
||||
truncated = True
|
||||
|
||||
# Compute reward
|
||||
if terminated or truncated:
|
||||
reward = self._compute_terminal_reward()
|
||||
else:
|
||||
reward = self._compute_step_reward()
|
||||
|
||||
obs = self._get_obs()
|
||||
info = self._get_info()
|
||||
self._last_observation = obs
|
||||
|
||||
return obs, reward, terminated, truncated, info
|
||||
|
||||
def _compute_step_reward(self) -> float:
|
||||
"""Compute reward for a non-terminal step."""
|
||||
if self.reward_shaping:
|
||||
# Small reward for each square placed
|
||||
return 0.01 * self.game.players[0].score
|
||||
return 0.0
|
||||
|
||||
def _compute_terminal_reward(self) -> float:
|
||||
"""Compute reward when the game ends."""
|
||||
scores = self.game.get_scores()
|
||||
agent_score = scores[0]
|
||||
|
||||
if self.reward_shaping:
|
||||
# Normalized score: agent score / max possible score
|
||||
max_possible = sum(p.size for p in self.piece_set.pieces)
|
||||
return agent_score / max_possible
|
||||
|
||||
# Win/loss reward
|
||||
max_score = max(scores)
|
||||
if agent_score == max_score:
|
||||
# Check if it's a tie
|
||||
winners = [i for i, s in enumerate(scores) if s == max_score]
|
||||
if len(winners) == 1:
|
||||
return 1.0 # Win
|
||||
else:
|
||||
return 0.0 # Tie
|
||||
else:
|
||||
return -1.0 # Loss
|
||||
|
||||
def render(self) -> str | np.ndarray | None:
|
||||
"""Render the environment.
|
||||
|
||||
Args:
|
||||
No arguments - uses self.render_mode.
|
||||
|
||||
Returns:
|
||||
For "human": None (prints to stdout)
|
||||
For "ansi": string representation
|
||||
For "rgb_array": numpy array of shape (H, W, 3)
|
||||
"""
|
||||
if self.render_mode is None:
|
||||
return None
|
||||
|
||||
if self.render_mode == "human":
|
||||
render_text_board(self.game)
|
||||
return None
|
||||
|
||||
elif self.render_mode == "ansi":
|
||||
return render_ansi_board(self.game)
|
||||
|
||||
elif self.render_mode == "rgb_array":
|
||||
return self._render_rgb_array()
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown render_mode: {self.render_mode}")
|
||||
|
||||
def _render_rgb_array(self) -> np.ndarray:
|
||||
"""Render the board as an RGB array using matplotlib."""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"matplotlib is required for rgb_array rendering. "
|
||||
"Install with: pip install matplotlib"
|
||||
) from exc
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 8))
|
||||
|
||||
# Color map: 0=empty(light grey), 1=red, 2=blue, 3=yellow, 4=green
|
||||
colors = {
|
||||
0: "#d3d3d3", # light grey
|
||||
1: "#ff6b6b", # red
|
||||
2: "#4dabf7", # blue
|
||||
3: "#ffd43b", # yellow
|
||||
4: "#51cf66", # green
|
||||
}
|
||||
|
||||
grid = self.game.board.grid.astype(float)
|
||||
colored = np.zeros((self.board_size, self.board_size, 3))
|
||||
for i in range(self.board_size):
|
||||
for j in range(self.board_size):
|
||||
val = int(grid[i, j])
|
||||
hex_color = colors.get(val, "#d3d3d3")
|
||||
# Parse hex color
|
||||
r = int(hex_color[1:3], 16) / 255
|
||||
g = int(hex_color[3:5], 16) / 255
|
||||
b = int(hex_color[5:7], 16) / 255
|
||||
colored[i, j] = [r, g, b]
|
||||
|
||||
ax.imshow(colored, interpolation="nearest")
|
||||
ax.set_xticks(np.arange(-0.5, self.board_size, 1), minor=True)
|
||||
ax.set_yticks(np.arange(-0.5, self.board_size, 1), minor=True)
|
||||
ax.grid(True, which="minor", color="black", linewidth=0.5)
|
||||
ax.set_xticks([])
|
||||
ax.set_yticks([])
|
||||
|
||||
# Title with current player and scores
|
||||
scores = self.game.get_scores()
|
||||
title = f"Current player: {self.game.current_player + 1}\n"
|
||||
title += "Scores: " + " | ".join(f"P{i+1}: {s}" for i, s in enumerate(scores))
|
||||
ax.set_title(title, fontsize=10)
|
||||
|
||||
plt.tight_layout()
|
||||
fig.canvas.draw()
|
||||
image = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8)
|
||||
image = image.reshape(fig.canvas.get_width_height()[::-1] + (3,))
|
||||
plt.close(fig)
|
||||
return image
|
||||
|
||||
def close(self) -> None:
|
||||
"""Clean up resources."""
|
||||
pass
|
||||
|
||||
def get_action_mask(self) -> np.ndarray:
|
||||
"""Get the action mask for the current player (player 0)."""
|
||||
return self.game.get_action_mask(0)
|
||||
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from gymnasium import spaces
|
||||
|
||||
from blokus_gym.core.game import BlokusGame
|
||||
from blokus_gym.core.pieces import STANDARD_PIECES, PieceSet
|
||||
|
||||
|
||||
class BlokusMultiAgentEnv:
|
||||
"""PettingZoo-style AEC (Actor-Environment-Cycle) environment for Blokus.
|
||||
|
||||
This wraps the BlokusGame core to provide a multi-agent interface where
|
||||
each player acts independently. Compatible with PettingZoo's API and
|
||||
RLlib's multi-agent RL.
|
||||
|
||||
Unlike the single-agent BlokusEnv, all players are controlled by the
|
||||
caller (no built-in bots). This allows for self-play training and
|
||||
evaluation of multi-agent policies.
|
||||
|
||||
Attributes:
|
||||
num_players: Number of players (2-4).
|
||||
board_size: Size of the square board.
|
||||
agents: List of agent names (e.g., ["player_0", "player_1", ...]).
|
||||
possible_agents: Same as agents (never changes).
|
||||
agent_order: Order in which agents take turns.
|
||||
|
||||
Example:
|
||||
>>> from blokus_gym import BlokusMultiAgentEnv
|
||||
>>> env = BlokusMultiAgentEnv(num_players=4)
|
||||
>>> obs, info = env.reset()
|
||||
>>> for agent in env.agent_iter():
|
||||
... obs, reward, terminated, truncated, info = env.last()
|
||||
... action = env.action_space(agent).sample() # Use action mask
|
||||
... env.step(action)
|
||||
"""
|
||||
|
||||
metadata = {"render_modes": ["human", "ansi"]}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_players: int = 4,
|
||||
board_size: int = 20,
|
||||
pieces: list | None = None,
|
||||
render_mode: str | None = None,
|
||||
corner_rule: bool = True,
|
||||
seed: int | None = None,
|
||||
):
|
||||
assert 2 <= num_players <= 4, f"num_players must be 2-4, got {num_players}"
|
||||
assert board_size >= 5, f"board_size must be >= 5, got {board_size}"
|
||||
|
||||
self.num_players = num_players
|
||||
self.board_size = board_size
|
||||
self.render_mode = render_mode
|
||||
self.corner_rule = corner_rule
|
||||
self._seed = seed
|
||||
|
||||
self.piece_set = PieceSet(pieces or STANDARD_PIECES)
|
||||
self.game: BlokusGame | None = None
|
||||
|
||||
self.agents = [f"player_{i}" for i in range(num_players)]
|
||||
self.possible_agents = list(self.agents)
|
||||
self.agent_order = list(self.agents)
|
||||
|
||||
# Observation and action spaces (same for all agents)
|
||||
self.observation_space = spaces.Dict({
|
||||
"board": spaces.Box(0, num_players, (board_size, board_size), dtype=np.int8),
|
||||
"pieces": spaces.MultiBinary(self.piece_set.num_pieces),
|
||||
"corners": spaces.Box(0, 1, (board_size, board_size), dtype=bool),
|
||||
})
|
||||
|
||||
self._cur_step = 0
|
||||
self._current_agent_idx = 0
|
||||
self._agent_dones: dict[str, bool] = {}
|
||||
|
||||
def reset(self, seed: int | None = None, options: dict | None = None) -> tuple[dict, dict]:
|
||||
"""Reset the environment.
|
||||
|
||||
Returns:
|
||||
obs: Dict mapping agent name to observation.
|
||||
info: Dict with auxiliary information.
|
||||
"""
|
||||
self.game = BlokusGame(
|
||||
board_size=self.board_size,
|
||||
pieces=self.piece_set,
|
||||
num_players=self.num_players,
|
||||
corner_rule=self.corner_rule,
|
||||
)
|
||||
self.game.reset()
|
||||
|
||||
self._cur_step = 0
|
||||
self._current_agent_idx = 0
|
||||
self._agent_dones = dict.fromkeys(self.agents, False)
|
||||
|
||||
# Get observation for the first agent
|
||||
first_agent = self.agent_order[0]
|
||||
obs = self._get_obs(first_agent)
|
||||
info = self._get_info(first_agent)
|
||||
|
||||
return {first_agent: obs}, info
|
||||
|
||||
def step(self, action: int) -> tuple[dict, dict, dict, dict, dict]:
|
||||
"""Execute one step. Must be called for the current agent in agent_iter().
|
||||
|
||||
Returns:
|
||||
observations: Dict mapping agent -> observation
|
||||
rewards: Dict mapping agent -> reward
|
||||
terminations: Dict mapping agent -> bool
|
||||
truncations: Dict mapping agent -> bool
|
||||
infos: Dict mapping agent -> info
|
||||
"""
|
||||
self._cur_step += 1
|
||||
agent = self.agent_order[self._current_agent_idx]
|
||||
player_idx = int(agent.split("_")[1])
|
||||
|
||||
# Apply the action
|
||||
success = self.game.play_move(player_idx, action)
|
||||
|
||||
# Compute rewards for this player
|
||||
reward = 0.0
|
||||
if not success:
|
||||
reward = -10.0 # Invalid action penalty
|
||||
self._agent_dones[agent] = True
|
||||
|
||||
# Check game over
|
||||
game_over = self.game.is_game_over()
|
||||
|
||||
# Advance to next player
|
||||
if not game_over:
|
||||
self.game.next_player()
|
||||
# Skip players who can't move
|
||||
while not self.game.is_game_over():
|
||||
player = self.game.current_player
|
||||
agent_name = self.agents[player]
|
||||
if self._agent_dones.get(agent_name, False):
|
||||
self.game.next_player()
|
||||
continue
|
||||
if not self.game.has_valid_moves(player):
|
||||
self.game.players[player].can_move = False
|
||||
self.game.next_player()
|
||||
continue
|
||||
break
|
||||
|
||||
# Determine next agent
|
||||
next_player = self.game.current_player
|
||||
next_agent = self.agents[next_player]
|
||||
|
||||
# Check if game is over
|
||||
if game_over:
|
||||
# Final rewards based on scores
|
||||
scores = self.game.get_scores()
|
||||
max_score = max(scores)
|
||||
terminations = {}
|
||||
rewards = {}
|
||||
for i, agent_name in enumerate(self.agents):
|
||||
if scores[i] == max_score:
|
||||
winners = [j for j, s in enumerate(scores) if s == max_score]
|
||||
if len(winners) == 1:
|
||||
rewards[agent_name] = 1.0
|
||||
else:
|
||||
rewards[agent_name] = 0.0
|
||||
else:
|
||||
rewards[agent_name] = -1.0
|
||||
terminations[agent_name] = True
|
||||
self._agent_dones[agent_name] = True
|
||||
|
||||
infos = {agent_name: {"step_count": self._cur_step} for agent_name in self.agents}
|
||||
observations = {agent_name: self._get_obs(agent_name) for agent_name in self.agents}
|
||||
|
||||
return observations, rewards, terminations, dict.fromkeys(self.agents, False), infos
|
||||
|
||||
# Normal step
|
||||
rewards = dict.fromkeys(self.agents, 0.0)
|
||||
rewards[agent] = reward
|
||||
terminations = {
|
||||
agent_name: self._agent_dones.get(agent_name, False)
|
||||
for agent_name in self.agents
|
||||
}
|
||||
truncations = dict.fromkeys(self.agents, False)
|
||||
|
||||
observations = {next_agent: self._get_obs(next_agent)}
|
||||
infos = {next_agent: self._get_info(next_agent)}
|
||||
|
||||
# Advance current agent index
|
||||
self._current_agent_idx = self.agents.index(next_agent)
|
||||
|
||||
return observations, rewards, terminations, truncations, infos
|
||||
|
||||
def observe(self, agent: str) -> dict:
|
||||
"""Get observation for a specific agent."""
|
||||
return self._get_obs(agent)
|
||||
|
||||
def get_action_mask(self, agent: str) -> np.ndarray:
|
||||
"""Get valid action mask for a specific agent."""
|
||||
player_idx = int(agent.split("_")[1])
|
||||
return self.game.get_action_mask(player_idx)
|
||||
|
||||
def action_space(self, agent: str) -> spaces.Discrete:
|
||||
"""Get action space for a specific agent."""
|
||||
return spaces.Discrete(self.game.num_actions)
|
||||
|
||||
def observation_space(self, agent: str) -> spaces.Dict:
|
||||
"""Get observation space for a specific agent."""
|
||||
return self.observation_space
|
||||
|
||||
def render(self) -> str | None:
|
||||
"""Render the current state."""
|
||||
if self.render_mode is None:
|
||||
return None
|
||||
|
||||
from blokus_gym.utils.render import render_ansi_board, render_text_board
|
||||
|
||||
if self.render_mode == "human":
|
||||
print(render_text_board(self.game))
|
||||
return None
|
||||
elif self.render_mode == "ansi":
|
||||
return render_ansi_board(self.game)
|
||||
else:
|
||||
raise ValueError(f"Unknown render_mode: {self.render_mode}")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Clean up resources."""
|
||||
pass
|
||||
|
||||
def _get_obs(self, agent: str) -> dict:
|
||||
"""Get observation from an agent's perspective."""
|
||||
obs = self.game.get_current_observation()
|
||||
return {
|
||||
"board": obs["board"],
|
||||
"pieces": obs["pieces"],
|
||||
"corners": obs["corners"],
|
||||
}
|
||||
|
||||
def _get_info(self, agent: str) -> dict:
|
||||
"""Get auxiliary info for an agent."""
|
||||
player_idx = int(agent.split("_")[1])
|
||||
return {
|
||||
"action_mask": self.game.get_action_mask(player_idx),
|
||||
"current_player": self.game.current_player,
|
||||
"step_count": self._cur_step,
|
||||
"scores": self.game.get_scores(),
|
||||
}
|
||||
|
||||
def agent_iter(self):
|
||||
"""Iterate over agents in turn order."""
|
||||
# This is a generator that yields the current agent
|
||||
while True:
|
||||
agent = self.agents[self.game.current_player]
|
||||
if self._agent_dones.get(agent, False):
|
||||
# Skip done agents
|
||||
if all(self._agent_dones.values()):
|
||||
break
|
||||
continue
|
||||
yield agent
|
||||
@@ -0,0 +1,17 @@
|
||||
from blokus_gym.utils.render import (
|
||||
ANSI_COLORS,
|
||||
ANSI_RESET,
|
||||
PLAYER_NAMES,
|
||||
print_board,
|
||||
render_ansi_board,
|
||||
render_text_board,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ANSI_COLORS",
|
||||
"ANSI_RESET",
|
||||
"PLAYER_NAMES",
|
||||
"render_ansi_board",
|
||||
"render_text_board",
|
||||
"print_board",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from blokus_gym.core.game import BlokusGame
|
||||
|
||||
# ANSI color codes for terminal rendering
|
||||
ANSI_COLORS = {
|
||||
0: "\033[90m", # grey (empty)
|
||||
1: "\033[91m", # red
|
||||
2: "\033[94m", # blue
|
||||
3: "\033[93m", # yellow
|
||||
4: "\033[92m", # green
|
||||
}
|
||||
ANSI_RESET = "\033[0m"
|
||||
|
||||
# Player names
|
||||
PLAYER_NAMES = {1: "Red", 2: "Blue", 3: "Yellow", 4: "Green"}
|
||||
|
||||
|
||||
def render_text_board(game: BlokusGame) -> str:
|
||||
"""Render the board as a text string (no ANSI colors).
|
||||
|
||||
Returns a string representation of the board.
|
||||
"""
|
||||
lines = []
|
||||
size = game.board_size
|
||||
|
||||
# Column headers
|
||||
header = " " + " ".join(str(i % 10) for i in range(size))
|
||||
lines.append(header)
|
||||
|
||||
for y in range(size):
|
||||
row = f"{y % 10} "
|
||||
for x in range(size):
|
||||
cell = game.board.get_cell(x, y)
|
||||
if cell == 0:
|
||||
row += ". "
|
||||
else:
|
||||
row += f"{cell} "
|
||||
lines.append(row)
|
||||
|
||||
# Player info
|
||||
lines.append("")
|
||||
for i, player in enumerate(game.players):
|
||||
remaining = len(player.available_pieces)
|
||||
total = len(game.piece_set.pieces)
|
||||
name = PLAYER_NAMES.get(i + 1, f"Player {i + 1}")
|
||||
lines.append(f"{name}: {remaining}/{total} pieces remaining, score: {player.score}")
|
||||
|
||||
cur_name = PLAYER_NAMES.get(game.current_player + 1, game.current_player + 1)
|
||||
lines.append(f"Current player: {cur_name}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_ansi_board(game: BlokusGame) -> str:
|
||||
"""Render the board with ANSI color codes.
|
||||
|
||||
Returns a string with ANSI escape sequences for colored output.
|
||||
"""
|
||||
lines = []
|
||||
size = game.board_size
|
||||
|
||||
# Column headers
|
||||
header = " " + " ".join(str(i % 10) for i in range(size))
|
||||
lines.append(header)
|
||||
|
||||
for y in range(size):
|
||||
row = f"{y % 10} "
|
||||
for x in range(size):
|
||||
cell = game.board.get_cell(x, y)
|
||||
color = ANSI_COLORS.get(cell, ANSI_RESET)
|
||||
if cell == 0:
|
||||
row += f"{color}. {ANSI_RESET}"
|
||||
else:
|
||||
row += f"{color}{cell} {ANSI_RESET}"
|
||||
lines.append(row)
|
||||
|
||||
# Player info
|
||||
lines.append("")
|
||||
for i, player in enumerate(game.players):
|
||||
remaining = len(player.available_pieces)
|
||||
total = len(game.piece_set.pieces)
|
||||
name = PLAYER_NAMES.get(i + 1, f"Player {i + 1}")
|
||||
color = ANSI_COLORS.get(i + 1, ANSI_RESET)
|
||||
lines.append(
|
||||
f"{color}{name}{ANSI_RESET}: {remaining}/{total} pieces, "
|
||||
f"score: {player.score}"
|
||||
)
|
||||
|
||||
lines.append(
|
||||
f"Current player: "
|
||||
f"{ANSI_COLORS.get(game.current_player + 1, ANSI_RESET)}"
|
||||
f"{PLAYER_NAMES.get(game.current_player + 1, game.current_player + 1)}"
|
||||
f"{ANSI_RESET}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def print_board(game: BlokusGame, use_ansi: bool = True) -> None:
|
||||
"""Print the board to stdout."""
|
||||
if use_ansi:
|
||||
print(render_ansi_board(game))
|
||||
else:
|
||||
print(render_text_board(game))
|
||||
@@ -0,0 +1,3 @@
|
||||
from blokus_gym.wrappers.action_mask import ActionMaskWrapper
|
||||
|
||||
__all__ = ["ActionMaskWrapper"]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from gymnasium import spaces
|
||||
from gymnasium.core import Env, Wrapper
|
||||
|
||||
|
||||
class ActionMaskWrapper(Wrapper):
|
||||
"""Wrapper that moves the action mask from info into the observation space.
|
||||
|
||||
This is useful for RL frameworks that expect the action mask to be part
|
||||
of the observation (e.g., Stable-Baselines3 with custom policies,
|
||||
RLlib, etc.).
|
||||
|
||||
After wrapping, the observation becomes a Dict with:
|
||||
- "observation": the original observation
|
||||
- "action_mask": boolean array of valid actions
|
||||
|
||||
Example:
|
||||
>>> from blokus_gym import BlokusEnv
|
||||
>>> from blokus_gym.wrappers import ActionMaskWrapper
|
||||
>>> env = BlokusEnv()
|
||||
>>> env = ActionMaskWrapper(env)
|
||||
>>> obs, info = env.reset()
|
||||
>>> obs["action_mask"] # Boolean mask of valid actions
|
||||
"""
|
||||
|
||||
def __init__(self, env: Env):
|
||||
super().__init__(env)
|
||||
|
||||
# Build new observation space
|
||||
original_obs_space = env.observation_space
|
||||
action_dim = env.action_space.n
|
||||
|
||||
self.observation_space = spaces.Dict({
|
||||
"observation": original_obs_space,
|
||||
"action_mask": spaces.Box(0, 1, (action_dim,), dtype=bool),
|
||||
})
|
||||
|
||||
def reset(self, *, seed=None, options=None):
|
||||
obs, info = self.env.reset(seed=seed, options=options)
|
||||
action_mask = info.get("action_mask", np.zeros(self.env.action_space.n, dtype=bool))
|
||||
return {"observation": obs, "action_mask": action_mask}, info
|
||||
|
||||
def step(self, action):
|
||||
obs, reward, terminated, truncated, info = self.env.step(action)
|
||||
action_mask = info.get("action_mask", np.zeros(self.env.action_space.n, dtype=bool))
|
||||
return {"observation": obs, "action_mask": action_mask}, reward, terminated, truncated, info
|
||||
Reference in New Issue
Block a user