From 4eb34bfeeb6265aa2a15fbdd356ee3a2890def36 Mon Sep 17 00:00:00 2001 From: "mattlamb227@gmail.com" Date: Wed, 5 Aug 2026 16:42:57 -0400 Subject: [PATCH] initial commit --- PLAN.md | 457 ++++++++++++++++++ examples/basic_game.py | 50 ++ examples/multiagent.py | 46 ++ examples/train_sb3.py | 54 +++ pyproject.toml | 65 +++ src/blokus_gym.egg-info/PKG-INFO | 33 ++ src/blokus_gym.egg-info/SOURCES.txt | 19 + src/blokus_gym.egg-info/dependency_links.txt | 1 + src/blokus_gym.egg-info/requires.txt | 17 + src/blokus_gym.egg-info/top_level.txt | 1 + src/blokus_gym/__init__.py | 39 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 997 bytes src/blokus_gym/core/__init__.py | 30 ++ .../core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 745 bytes .../core/__pycache__/board.cpython-312.pyc | Bin 0 -> 5834 bytes .../core/__pycache__/bots.cpython-312.pyc | Bin 0 -> 8190 bytes .../core/__pycache__/game.cpython-312.pyc | Bin 0 -> 19236 bytes .../core/__pycache__/pieces.cpython-312.pyc | Bin 0 -> 12747 bytes src/blokus_gym/core/board.py | 89 ++++ src/blokus_gym/core/bots.py | 168 +++++++ src/blokus_gym/core/game.py | 414 ++++++++++++++++ src/blokus_gym/core/pieces.py | 265 ++++++++++ src/blokus_gym/envs/__init__.py | 97 ++++ .../envs/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1885 bytes .../__pycache__/blokus_env.cpython-312.pyc | Bin 0 -> 16344 bytes .../__pycache__/multiagent.cpython-312.pyc | Bin 0 -> 11866 bytes src/blokus_gym/envs/blokus_env.py | 366 ++++++++++++++ src/blokus_gym/envs/multiagent.py | 254 ++++++++++ src/blokus_gym/utils/__init__.py | 17 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 413 bytes .../utils/__pycache__/render.cpython-312.pyc | Bin 0 -> 5114 bytes src/blokus_gym/utils/render.py | 105 ++++ src/blokus_gym/wrappers/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 273 bytes .../__pycache__/action_mask.cpython-312.pyc | Bin 0 -> 3003 bytes src/blokus_gym/wrappers/action_mask.py | 48 ++ .../test_board.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 26423 bytes .../test_envs.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 36107 bytes .../test_game.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 36782 bytes .../test_imports.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 11518 bytes .../test_pieces.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 18987 bytes tests/test_board.py | 97 ++++ tests/test_envs.py | 197 ++++++++ tests/test_game.py | 196 ++++++++ tests/test_imports.py | 74 +++ tests/test_pieces.py | 85 ++++ 46 files changed, 3287 insertions(+) create mode 100644 PLAN.md create mode 100644 examples/basic_game.py create mode 100644 examples/multiagent.py create mode 100644 examples/train_sb3.py create mode 100644 pyproject.toml create mode 100644 src/blokus_gym.egg-info/PKG-INFO create mode 100644 src/blokus_gym.egg-info/SOURCES.txt create mode 100644 src/blokus_gym.egg-info/dependency_links.txt create mode 100644 src/blokus_gym.egg-info/requires.txt create mode 100644 src/blokus_gym.egg-info/top_level.txt create mode 100644 src/blokus_gym/__init__.py create mode 100644 src/blokus_gym/__pycache__/__init__.cpython-312.pyc create mode 100644 src/blokus_gym/core/__init__.py create mode 100644 src/blokus_gym/core/__pycache__/__init__.cpython-312.pyc create mode 100644 src/blokus_gym/core/__pycache__/board.cpython-312.pyc create mode 100644 src/blokus_gym/core/__pycache__/bots.cpython-312.pyc create mode 100644 src/blokus_gym/core/__pycache__/game.cpython-312.pyc create mode 100644 src/blokus_gym/core/__pycache__/pieces.cpython-312.pyc create mode 100644 src/blokus_gym/core/board.py create mode 100644 src/blokus_gym/core/bots.py create mode 100644 src/blokus_gym/core/game.py create mode 100644 src/blokus_gym/core/pieces.py create mode 100644 src/blokus_gym/envs/__init__.py create mode 100644 src/blokus_gym/envs/__pycache__/__init__.cpython-312.pyc create mode 100644 src/blokus_gym/envs/__pycache__/blokus_env.cpython-312.pyc create mode 100644 src/blokus_gym/envs/__pycache__/multiagent.cpython-312.pyc create mode 100644 src/blokus_gym/envs/blokus_env.py create mode 100644 src/blokus_gym/envs/multiagent.py create mode 100644 src/blokus_gym/utils/__init__.py create mode 100644 src/blokus_gym/utils/__pycache__/__init__.cpython-312.pyc create mode 100644 src/blokus_gym/utils/__pycache__/render.cpython-312.pyc create mode 100644 src/blokus_gym/utils/render.py create mode 100644 src/blokus_gym/wrappers/__init__.py create mode 100644 src/blokus_gym/wrappers/__pycache__/__init__.cpython-312.pyc create mode 100644 src/blokus_gym/wrappers/__pycache__/action_mask.cpython-312.pyc create mode 100644 src/blokus_gym/wrappers/action_mask.py create mode 100644 tests/__pycache__/test_board.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_envs.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_game.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_imports.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_pieces.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/test_board.py create mode 100644 tests/test_envs.py create mode 100644 tests/test_game.py create mode 100644 tests/test_imports.py create mode 100644 tests/test_pieces.py diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..692b197 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,457 @@ +# Blokus Gym Harness — Design & Implementation Plan + +## 1. Overview + +A Gymnasium-compatible RL environment for the board game **Blokus**, supporting both **single-agent** (agent vs. configurable bots) and **multi-agent** (PettingZoo-compatible) modes. The harness uses **numpy** for maximum training speed and broad framework compatibility, with **matplotlib** as an optional rendering backend. + +## 2. File Structure + +``` +blokus/ +├── pyproject.toml # Package config, dependencies, entry points +├── README.md +├── LICENSE +├── PLAN.md # This file +├── src/blokus_gym/ +│ ├── __init__.py # Package exports +│ ├── envs/ +│ │ ├── __init__.py # Env exports +│ │ ├── blokus_env.py # Main gymnasium.Env implementation +│ │ ├── multiagent.py # PettingZoo-compatible multi-agent wrapper +│ │ └── registration.py # gymnasium.register() calls for presets +│ ├── core/ +│ │ ├── __init__.py +│ │ ├── pieces.py # Piece definitions, orientation generation, custom piece sets +│ │ ├── board.py # Board state (numpy array), placement validation, corner tracking +│ │ ├── game.py # Game rules: turn order, move validation, scoring, game-over detection +│ │ └── bots.py # Bot implementations: Random, Greedy, Minimax +│ ├── wrappers/ +│ │ ├── __init__.py +│ │ └── action_mask.py # Optional wrapper to expose action masks as part of observation +│ └── utils/ +│ ├── __init__.py +│ └── render.py # Text/ANSI and matplotlib rendering +├── tests/ +│ ├── __init__.py +│ ├── conftest.py # Shared fixtures +│ ├── test_pieces.py +│ ├── test_board.py +│ ├── test_game.py +│ ├── test_env.py +│ ├── test_multiagent.py +│ └── test_registration.py +└── examples/ + ├── basic_usage.py + ├── train_sb3.py # Stable-Baselines3 training example + ├── train_rllib.py # Ray RLlib training example + └── multiagent_example.py +``` + +## 3. Core Design Decisions + +### 3.1 Action Space: Discrete + Action Mask + +**Design**: Pre-compute all unique `(piece, orientation, position)` combinations into a flat `Discrete(N)` action space. At each step, provide an action mask in `info["action_mask"]` indicating which of the N actions are currently valid. + +**Why**: +- Works with any RL algorithm (masking-aware libs like SB3, RLlib use the mask; others can ignore it) +- Simple, standard API +- Action index is stable across episodes (important for policy networks) + +**Action space size** (standard 20×20 board, 21 pieces): ~25,000 discrete actions. Smaller boards scale down proportionally. + +**Action generation algorithm** (at `__init__` time): +1. For each piece in the piece set, generate all unique orientations (rotations × flips, deduplicated) +2. For each orientation, enumerate all positions where the piece fits on the board +3. Assign each `(piece, orientation, x, y)` a unique integer index +4. Store as a list of `Move` objects (piece_id, orientation_id, x, y) for O(1) lookup + +**Action mask computation** (at each `step`): +- Start with all-False mask +- For each action index, check if the move is valid (piece available, in bounds, no overlap, corner rule satisfied) +- Set valid indices to True + +### 3.2 Observation Space: Dict with Board, Pieces, and Corners + +```python +observation_space = spaces.Dict({ + "board": spaces.Box(0, num_players, (board_size, board_size), dtype=np.int8), + "pieces": spaces.MultiBinary(num_pieces), # 1 = still available + "my_turn": spaces.Discrete(1), # Always 1 (agent's perspective) + "corners": spaces.Box(0, board_size, (board_size, board_size), dtype=bool), # Valid corner cells +}) +``` + +**Info dict**: +```python +info = { + "action_mask": np.array([bool] * num_actions), # Valid actions + "current_player": int, + "players_with_moves": [int, ...], # Who still has valid moves + "step_count": int, +} +``` + +### 3.3 Reward Design + +**Single-agent mode** (agent is player 0): +- **Step reward**: `0` by default (sparse). Optional shaping: `+0.01 * piece_size` for each placed piece +- **Terminal reward**: Based on final score comparison + - `+1.0` if agent wins (higher score than all opponents) + - `0.0` if tie + - `-1.0` if agent loses + - Alternative: normalized score difference `agent_score / max_possible_score` + +**Multi-agent mode** (PettingZoo): +- Each agent gets `reward = placed_squares / total_squares` at game end +- Penalty for unplaced squares: `-unplaced_squares / total_squares` +- `+15/total_squares` bonus for emptying all pieces +- `+5/total_squares` bonus for last piece being monomino + +### 3.4 Dependencies + +| Dependency | Purpose | Required? | +|---|---|---| +| `numpy` | Board state, coordinate math, action masks | Yes | +| `gymnasium` | RL environment API | Yes | +| `pettingzoo` | Multi-agent wrapper | Yes (for multi-agent mode) | +| `matplotlib` | Visual rendering | No (optional) | + +**Rationale**: numpy is the universal standard for array operations in Python RL. Every framework (SB3, RLlib, Tianshou, etc.) consumes numpy arrays natively. No torch/tensorflow dependency means faster installation and broader compatibility. + +## 4. Component Designs + +### 4.1 Pieces (`core/pieces.py`) + +**Piece definition**: A piece is a set of `(x, y)` integer coordinates relative to a reference point. + +```python +@dataclass +class Piece: + name: str + squares: frozenset[tuple[int, int]] # Relative coordinates + size: int # Number of squares + +@dataclass +class OrientedPiece: + piece_id: int + orientation_id: int + squares: list[tuple[int, int]] # Absolute coordinates on board + corners: list[tuple[int, int]] # Corner cells for placement rule +``` + +**Standard piece set** (`STANDARD_PIECES`): All 21 free polyominoes (1 monomino, 1 domino, 2 trominoes, 5 tetrominoes, 12 pentominoes). + +**Orientation generation**: +1. Start with original coordinates +2. Apply 4 rotations (0°, 90°, 180°, 270°) using rotation matrix `(x, y) → (y, -x)` +3. For each rotation, apply 2 flips: original and horizontal-flip `(x, y) → (-x, y)` +4. Normalize each result (shift so min x = 0, min y = 0) +5. Deduplicate by comparing sorted coordinate sets + +**Custom piece sets**: +```python +# Option 1: Define custom pieces +custom_pieces = [ + Piece("A", frozenset([(0,0), (1,0), (0,1)])), # L-tromino + Piece("B", frozenset([(0,0), (1,0), (2,0)])), # I-tromino +] + +# Option 2: Use a preset +from blokus_gym.core.pieces import STANDARD_PIECES, DUO_PIECES, JUNIOR_PIECES + +# Option 3: Load from JSON +pieces = PieceSet.from_json("my_pieces.json") + +# Option 4: Filter standard set +pentominoes_only = [p for p in STANDARD_PIECES if p.size == 5] +``` + +### 4.2 Board (`core/board.py`) + +```python +class Board: + def __init__(self, size: int): + self.size = size + self.grid = np.zeros((size, size), dtype=np.int8) # 0 = empty, 1-4 = player + + def place(self, player_idx: int, squares: list[tuple[int, int]]) -> None + def is_empty(self, x: int, y: int) -> bool + def in_bounds(self, x: int, y: int) -> bool + def has_overlap(self, squares: list[tuple[int, int]]) -> bool + def get_player_corners(self, player_idx: int) -> set[tuple[int, int]] +``` + +**Key methods**: +- `place()`: Sets grid cells to player index +- `has_overlap()`: Checks if any square is already occupied +- `get_player_corners()`: Returns all corner cells adjacent to player's pieces (for placement rule) + +### 4.3 Game Logic (`core/game.py`) + +```python +class BlokusGame: + def __init__(self, board: Board, pieces: list[Piece], num_players: int = 4): + self.board = board + self.pieces = pieces + self.num_players = num_players + self.current_player = 0 + self.player_pieces: list[set[str]] # Available piece names per player + self.player_corners: list[set[tuple[int, int]]] + self.rounds = 0 + self.game_over = False + + def valid_move(self, player_idx: int, move: Move) -> bool + def apply_move(self, player_idx: int, move: Move) -> None + def get_valid_moves(self, player_idx: int) -> list[int] # Returns action indices + def get_action_mask(self, player_idx: int) -> np.ndarray + def next_player(self) -> int # Cycles to next player with moves + def is_game_over(self) -> bool + def get_scores(self) -> list[int] # Negative = unplaced squares + def get_winners(self) -> list[int] +``` + +**Move validation rules**: +1. Piece must be in player's available pieces +2. All squares must be in bounds +3. No overlap with existing pieces +4. **Corner rule**: At least one square of the placed piece must touch a same-color piece at a corner (diagonal adjacency) +5. **No edge adjacency**: No square of the placed piece may share an edge with a same-color piece +6. **First move**: Must be placed in a corner (if `corner_rule=True`) + +### 4.4 Bots (`core/bots.py`) + +```python +class Bot(ABC): + @abstractmethod + def select_action(self, game: BlokusGame, player_idx: int, valid_actions: np.ndarray) -> int + +class RandomBot(Bot): + """Randomly selects from valid actions.""" + +class GreedyBot(Bot): + """Selects the move that places the most squares (largest piece).""" + +class GreedyCornersBot(Bot): + """Greedy + prefers moves that open up more corners for future play.""" + +class MinimaxBot(Bot): + """Minimax search with configurable depth. Primarily for 2-player games.""" +``` + +### 4.5 Gymnasium Environment (`envs/blokus_env.py`) + +```python +class BlokusEnv(gymnasium.Env): + metadata = {"render_modes": ["human", "ansi", "rgb_array"]} + + def __init__( + self, + num_players: int = 4, + board_size: int = 20, + pieces: list[Piece] | None = None, # Default: STANDARD_PIECES + 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, + ): + super().__init__() + # Initialize game, spaces, action lookup table + + def reset(self, seed=None, options=None) -> tuple[dict, dict] + def step(self, action: int) -> tuple[dict, float, bool, bool, dict] + def render(self) -> str | np.ndarray | None + def close(self) +``` + +**Key implementation details**: +- `__init__` generates the full action lookup table (piece, orientation, position → index) +- `reset()` initializes a new game, places bots, optionally lets bots take first turns +- `step()` applies the agent's action, then lets bots play until it's the agent's turn again +- Returns `(observation, reward, terminated, truncated, info)` where `info` contains the action mask + +### 4.6 Multi-Agent Wrapper (`envs/multiagent.py`) + +PettingZoo-compatible wrapper that exposes all players as separate agents: + +```python +class BlokusMultiAgentEnv(AECEnv): + def __init__(self, **kwargs): + # Same config as BlokusEnv, but wraps it + + def reset(self, seed=None, options=None) -> tuple[dict, dict] + def step(self, action: int) -> None + # PettingZoo API: observe(), close(), render(), etc. + + @property + def agents(self) -> list[str] # ["player_0", "player_1", ...] + @property + def agent_selection(self) -> str +``` + +**Behavior**: +- Cycles through players in order (0, 1, 2, 3) +- Each agent gets its own observation (board + own pieces + own corners + action mask) +- Rewards are per-agent: negative for unplaced squares, bonus for emptying pieces +- Agents with no valid moves are marked as done (truncated) but still receive final rewards + +### 4.7 Registration (`envs/registration.py`) + +Predefined environment configurations registered with `gymnasium.register()`: + +```python +# Standard 4-player Blokus +"Blokus-v0": num_players=4, board_size=20, pieces=STANDARD_PIECES + +# Blokus Duo (2 players, 14x14) +"BlokusDuo-v0": num_players=2, board_size=14, pieces=STANDARD_PIECES + +# Blokus Junior (2 players, 14x14, simplified pieces) +"BlokusJunior-v0": num_players=2, board_size=14, pieces=JUNIOR_PIECES + +# Simple test env (2 players, 7x7, only pieces < 5 squares) +"BlokusSimple-v0": num_players=2, board_size=7, pieces=[p for p in STANDARD_PIECES if p.size < 5] + +# Greedy bot variants +"BlokusGreedy-v0": bot_type=GreedyBot +"BlokusDuoGreedy-v0": bot_type=GreedyBot, num_players=2, board_size=14 +``` + +### 4.8 Rendering (`utils/render.py`) + +**Text/ANSI mode** (`render_mode="human"`): +``` + 0 1 2 3 4 5 6 7 8 9 ... +0 . . . . . . . . . . +1 . . . . . . . . . . +2 . . . . . . . . . . +3 . . . . . . . . . . +4 . . . . . . . . . . +... +Player 0 (Red): I1 I2 I3 L4 O4 I4 S4 T4 I5 L5 N P T U V W X Y Z +Player 1 (Blue): I1 I2 I3 L4 O4 I4 S4 T4 I5 L5 N P T U V W X Y Z +Current player: 0 +Valid moves: 152 +``` + +**RGB array mode** (`render_mode="rgb_array"`): +- Matplotlib figure with colored squares (red, blue, yellow, green, grey) +- Grid lines +- Legend showing remaining pieces per player +- Valid move indicators (semi-transparent overlays) + +### 4.9 Action Mask Wrapper (`wrappers/action_mask.py`) + +Optional wrapper that moves the action mask from `info` into the observation space: + +```python +class ActionMaskWrapper(Wrapper): + def __init__(self, env): + super().__init__(env) + self.observation_space = spaces.Dict({ + "observation": env.observation_space, + "action_mask": spaces.Box(0, 1, (env.action_space.n,), dtype=bool), + }) + + def step(self, action): + obs, reward, terminated, truncated, info = self.env.step(action) + return { + "observation": obs, + "action_mask": info["action_mask"], + }, reward, terminated, truncated, info +``` + +## 5. Customization Options + +All options are passed as keyword arguments to `BlokusEnv()` or via `gymnasium.make()`: + +| Option | Type | Default | Description | +|---|---|---|---| +| `num_players` | int | 4 | Number of players (2-4) | +| `board_size` | int | 20 | Board dimension (standard: 20, Duo: 14) | +| `pieces` | list[Piece] | STANDARD_PIECES | Custom piece set | +| `bot_type` | type[Bot] | RandomBot | Opponent bot type | +| `bot_strength` | int | 1 | Bot search depth (for Minimax) | +| `reward_shaping` | bool | False | Enable intermediate rewards | +| `corner_rule` | bool | True | Require first move in corner | +| `max_steps` | int | None | Max steps before truncation | +| `render_mode` | str | None | "human", "ansi", "rgb_array" | +| `seed` | int | None | Random seed | +| `starting_corners` | list | [(0,0), (0,19), (19,0), (19,19)] | Custom starting corners | +| `scoring_mode` | str | "standard" | "standard", "coverage", "winloss" | +| `first_player` | int | 0 | Which player goes first (agent is always 0) | + +## 6. Implementation Phases + +### Phase 1: Core Game Logic (Days 1-3) +- [ ] `core/pieces.py` — Piece definitions, orientation generation, custom piece support +- [ ] `core/board.py` — Board state, placement validation, corner tracking +- [ ] `core/game.py` — Game rules, move validation, scoring, game-over detection +- [ ] Unit tests for all three modules + +### Phase 2: Bots (Days 4-5) +- [ ] `core/bots.py` — Random, Greedy, GreedyCorners, Minimax bots +- [ ] Tests for bot behavior + +### Phase 3: Gymnasium Environment (Days 6-8) +- [ ] `envs/blokus_env.py` — Main environment class +- [ ] Action space generation and masking +- [ ] Observation and reward design +- [ ] `envs/registration.py` — Preset registrations +- [ ] `wrappers/action_mask.py` — Optional action mask wrapper +- [ ] `gymnasium.utils.env_checker.check_env()` passes + +### Phase 4: Multi-Agent (Days 9-10) +- [ ] `envs/multiagent.py` — PettingZoo AECEnv wrapper +- [ ] Per-agent observations and rewards +- [ ] Tests for multi-agent mode + +### Phase 5: Rendering (Days 11-12) +- [ ] `utils/render.py` — Text/ANSI and matplotlib rendering +- [ ] Integration with environment + +### Phase 6: Testing & Examples (Days 13-14) +- [ ] Comprehensive test suite (unit + integration) +- [ ] `examples/basic_usage.py` — Basic usage +- [ ] `examples/train_sb3.py` — SB3 training +- [ ] `examples/train_rllib.py` — RLlib training +- [ ] `examples/multiagent_example.py` — Multi-agent +- [ ] `pyproject.toml` with all dependencies +- [ ] `README.md` with full documentation + +## 7. Testing Strategy + +### Unit Tests +- **Pieces**: Orientation generation correctness, deduplication, custom piece loading +- **Board**: Placement, overlap detection, bounds checking, corner computation +- **Game**: Move validation (all rules), scoring, game-over detection, turn cycling +- **Bots**: Random bot returns valid moves, greedy bot prefers larger pieces + +### Integration Tests +- **Environment**: `check_env()` passes, reset/step return correct types +- **Action mask**: All masked actions are valid, all valid actions are masked +- **Game flow**: Full game from start to end, correct winner detection +- **Multi-agent**: All agents receive observations, rewards, and termination signals + +### Edge Cases +- Player with no valid moves (should be skipped) +- Game ends with all players blocked +- Last piece is monomino (bonus scoring) +- Custom piece sets with unusual shapes +- Small boards where pieces don't fit + +## 8. Key Design Tradeoffs + +| Decision | Choice | Rationale | +|---|---|---| +| Gym library | Gymnasium | Maintained fork, latest API | +| Agent mode | Single + Multi-agent | Covers both use cases | +| Action space | Discrete + mask | Broadest RL framework compatibility | +| Dependencies | numpy only | Fastest, broadest compatibility | +| Rendering | Text + matplotlib | No Pygame dependency, optional | +| Piece definition | Coordinate sets | Simple, flexible for custom pieces | +| Action pre-computation | At init time | Stable action indices, fast step() | +| Multi-agent | PettingZoo AECEnv | Standard API, compatible with RLlib/PettingZoo algorithms | diff --git a/examples/basic_game.py b/examples/basic_game.py new file mode 100644 index 0000000..0f7d0e3 --- /dev/null +++ b/examples/basic_game.py @@ -0,0 +1,50 @@ +"""Basic example: play a random game of Blokus.""" +import numpy as np + +from blokus_gym import BlokusEnv + + +def main(): + # Create environment with 4 players + env = BlokusEnv(num_players=4, board_size=20, render_mode="ansi") + + obs, info = env.reset(seed=42) + print("=== Initial State ===") + print(env.render()) + + total_reward = 0 + steps = 0 + terminated = False + truncated = False + + while not (terminated or truncated): + # Select a random valid action + valid_actions = np.where(info["action_mask"])[0] + if len(valid_actions) == 0: + print("No valid moves!") + break + + action = np.random.choice(valid_actions) + obs, reward, terminated, truncated, info = env.step(action) + total_reward += reward + steps += 1 + + if steps % 10 == 0: + print(f"\n=== Step {steps} ===") + print(env.render()) + print(f"Reward: {reward}, Total: {total_reward}") + + print("\n=== Game Over ===") + print(f"Total steps: {steps}") + print(f"Total reward: {total_reward}") + print(f"Terminated: {terminated}, Truncated: {truncated}") + + # Final state + print("\n=== Final Board ===") + print(env.render()) + + env.close() + + +if __name__ == "__main__": + main() diff --git a/examples/multiagent.py b/examples/multiagent.py new file mode 100644 index 0000000..d3a4d5f --- /dev/null +++ b/examples/multiagent.py @@ -0,0 +1,46 @@ +"""Example: multi-agent self-play with BlokusMultiAgentEnv.""" +import numpy as np + +from blokus_gym import BlokusMultiAgentEnv, GreedyBot, RandomBot + + +def main(): + env = BlokusMultiAgentEnv(num_players=2, board_size=7) + + # Create bots for each player + bots = { + "player_0": RandomBot(player_idx=0, seed=42), + "player_1": GreedyBot(player_idx=1), + } + + obs, info = env.reset(seed=42) + print("=== Multi-Agent Game ===") + print(f"Agents: {env.agents}") + + steps = 0 + while True: + for agent in env.agents: + obs, info = env.last() if hasattr(env, "last") else (obs.get(agent, {}), {}) + mask = env.get_action_mask(agent) + valid_actions = np.where(mask)[0] + + if len(valid_actions) > 0: + bot = bots[agent] + action = bot.select_action(env.game, mask) + if action is not None: + obs, rewards, terminations, truncations, infos = env.step(action) + steps += 1 + + if terminations[agent]: + print(f"Game over after {steps} steps") + scores = env.game.get_scores() + for i, score in enumerate(scores): + print(f" Player {i}: {score}") + env.close() + return + + env.close() + + +if __name__ == "__main__": + main() diff --git a/examples/train_sb3.py b/examples/train_sb3.py new file mode 100644 index 0000000..5cdf4bb --- /dev/null +++ b/examples/train_sb3.py @@ -0,0 +1,54 @@ +"""Example: train an agent with Stable-Baselines3 using action masking. + +Requires: pip install blokus-gym[train] +""" +from stable_baselines3 import PPO + +from blokus_gym import ActionMaskWrapper, BlokusEnv + + +def main(): + # Create environment with ActionMaskWrapper + env = BlokusEnv(num_players=2, board_size=7, bot_type=None) + env = ActionMaskWrapper(env) + + # Train PPO agent + model = PPO( + "MultiInputPolicy", + env, + verbose=1, + learning_rate=3e-4, + n_steps=2048, + batch_size=64, + n_epochs=10, + gamma=0.99, + seed=42, + ) + + print("Training for 10,000 steps...") + model.learn(total_timesteps=10000) + + # Save the model + model.save("blokus_ppo_agent") + + # Evaluate + obs, info = env.reset(seed=42) + total_reward = 0 + steps = 0 + terminated = False + truncated = False + + while not (terminated or truncated): + action, _ = model.predict(obs, deterministic=True) + obs, reward, terminated, truncated, info = env.step(action) + total_reward += reward + steps += 1 + + print(f"Evaluation: {steps} steps, reward: {total_reward}") + + # Cleanup + env.close() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d711891 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,65 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "blokus-gym" +version = "0.1.0" +description = "A Gymnasium-compatible RL environment for the board game Blokus" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [{name = "Blokus Gym Contributors"}] +keywords = ["reinforcement-learning", "gymnasium", "blokus", "board-game", "pettingzoo"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "numpy>=1.24", + "gymnasium>=0.29", + "pettingzoo>=1.26", +] + +[project.optional-dependencies] +render = ["matplotlib>=3.7"] +dev = [ + "pytest>=7.4", + "pytest-cov>=4.1", + "black>=23.7", + "ruff>=0.1.0", + "mypy>=1.5", +] +train = [ + "stable-baselines3>=2.2", + "torch>=2.0", +] + +[project.urls] +Homepage = "https://github.com/blokus-gym/blokus-gym" +Documentation = "https://github.com/blokus-gym/blokus-gym#readme" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "--tb=short -q" + +[tool.black] +line-length = 100 +target-version = ["py310"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B", "C4"] diff --git a/src/blokus_gym.egg-info/PKG-INFO b/src/blokus_gym.egg-info/PKG-INFO new file mode 100644 index 0000000..505c211 --- /dev/null +++ b/src/blokus_gym.egg-info/PKG-INFO @@ -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" diff --git a/src/blokus_gym.egg-info/SOURCES.txt b/src/blokus_gym.egg-info/SOURCES.txt new file mode 100644 index 0000000..e665378 --- /dev/null +++ b/src/blokus_gym.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/src/blokus_gym.egg-info/dependency_links.txt b/src/blokus_gym.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/blokus_gym.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/blokus_gym.egg-info/requires.txt b/src/blokus_gym.egg-info/requires.txt new file mode 100644 index 0000000..846a800 --- /dev/null +++ b/src/blokus_gym.egg-info/requires.txt @@ -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 diff --git a/src/blokus_gym.egg-info/top_level.txt b/src/blokus_gym.egg-info/top_level.txt new file mode 100644 index 0000000..66d72bf --- /dev/null +++ b/src/blokus_gym.egg-info/top_level.txt @@ -0,0 +1 @@ +blokus_gym diff --git a/src/blokus_gym/__init__.py b/src/blokus_gym/__init__.py new file mode 100644 index 0000000..759f955 --- /dev/null +++ b/src/blokus_gym/__init__.py @@ -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__", +] diff --git a/src/blokus_gym/__pycache__/__init__.cpython-312.pyc b/src/blokus_gym/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dddead7657e275238a1d1ccca55a368d6358619c GIT binary patch literal 997 zcmZXS&rcIU6vt<`-EM!--z_2@h$pj`Eu2h@KT;?mm=p*|By*W8?W7j=?KZR9YCY+H zU_9uZn}3UEFOv{VJb6=zA>rh_SqKQ5>}S7ke!TbQJNva*%oFt-eT)u1r3m?f!R2FK zh|>>Bd?tO;H+%vHH4N0IZ$gTuAWhR?Q42CO12(nwT*}Wvj^-dw^EyuZ1(=~TP^3jD z(Grwt87i~_Ra%7_t?4zEUxx;5Xr1w!FiU5(w*5OWN9UBz(z^rF&m9?bewud*Ke0W> zkk)eOiN0gW)N&Zh+?wFLKSfzdggds(cXl_~*81wo>W(Zv-`!Z>+`bIUJ1^QBo%VL;a=_Xe@*bBJ{n->l9>iWe z3h?!crj<;)qJG-&n2~2gNX|2Nzfhp@vyCS#K~~A-NSTUFFJfFywQk@ zx7MP&Z8;po~(~t;_aF_#CRt1;2Wz5cMP-k`Um{;1BtN~5d1fTiPVl4<*03i#Z z&DyZVwkVmB)VZSU$*LN;Kd@;;wbPS{N+U>K_XY&~( z@5;ncGEXHKHL|qV%jBD7ewcvJm1FrYTT$8h}*2wr1KR7e99Fc2Ui!Lg#^1g*w%$M%}_jJY!g zcDq)2h$_3KvYke=O+Lu1`#g0khu2QK|@{*T`u#u|r(w=kg%zFLP zq^f$Qxqox-nS0Oq&Ueq9ztq%J3y^;IwmbCPMnU)-8$nWdMJ(-s%8Wn+B4&hPu~!ta zE@h-%SrSeOM1D&k${mT1?p3mqu6~IPT`X=eY}@e+FYVawIg^jzxj$Siy#?(V!4i5! zBJ@f`?3IbstB`sStMsa*fhbUhNSvrpR*^;$f-($c6_gRuM8Z&3lV%cuasz20)lhCE z39CyvdQXd4bpKdlhLjinNiDqo^@d7UhR-( z4Uc%)a!+Ube&`#?7+H&IX`5Krv|XL;eTEB?`yHy8psTd&S+<$guBN>~?O4XSH0nZs z#-XmxFbaw6pbYdf>;`Y%8ZZ~^FoTfz|4a;JPr9q4cb`3Sg~L7%F>2Hm2Y|d zeK9aF$Supr3c7R-%&ZbGJ7Vc-QE}5bt0>tcMKxzp$1N&p+dEKH256e-61=uD{WJ_B zUHqD+JvrJj=nPvO!-nT|bUEheFlgBA7@^LPWkRKoZN0;#re94BWQRLUhgu!XEXk2< zF`P=J?X;Il<)X}f$)H_}-`Nh?bzv#qGOpZ+d=_t-d2#y1SyJelee)0X_nJR!ei%PE zuKXfWf?JlzC3BsGO@G*HzK?S3#SEsy^m;0p`p!-IPB4kBf z%I$bHW0;m^_%?sW)ke}5bU3!=4f^;>o|_XXIG~`bv<8Y|H3vv4O|BKII2_%gc zU0fMLOQTU7slrIp%5C7AXN@@YyE`GfF8n>d>9HV2^og$VqvMyCw(J`3nmj#mddirs zn<77Z9w(G6bXEpJrw9PSTEhT(I% z#I{{j`qP;V=m0nGqKII3X2vpTushaB;E;OAV0Yn-laYzYWOO1rb@}6Pg72hqUFDtZ zh6!NCb$61N&;w8|5iji$gx3Jqxy127%e+QLRATVjf!m-MrU;V??fv-DPcHarVx5J(a^ zDqRU>W>{Theoom=S12Wtwo`r1s7>5ljAORku@%+Q;j5)~>gXr2t+UFl$j!*Z*w&9? z2MWgR&QEq8xb1zAoy#ulJTMNa{J3_GVE+{jQHBg0IMU2j1095lGPm3 z90evI+!K0Ef-7k76;1;R2NrsA1-cYy0=j(P*f40gDd&nsGsZ}+j_b0bs|^ObhavM> zK#Dx&vp_@h%;D+7g{_724;!Ajz5j!Qa|iF9zc1eHnLBddSbX-#Uz;~QQepU`kWkzB zElAt}!oS<6z?;O}v!{F(;JoG^e1;b|*RJwGsVA9ht%IEKfaRso6$5t)+8Cg?sH!#Y zG#}ow794m00VKuJpV<1nG8EP|xUl}2i98W-9Xk{NR$dhdWC>wH#P_8;2z^Lh!lW{( zn(_d6)WuCc5S202i}3dVJ|&P=(WeG|{nE5_W3vKB)R;P^aKGgBs{*iW8*0}8j`B~6 zGPblnh*A85C)qP67DKK_)2274Qv7mJy*g-7i&LTd{Ngh(=y#MyW^IAQ!nHeT00l(_DzJw zrSW5*weP)Kf4BRA^058Hr?Hdo^%TzE7T;-@I{(Y2Up360f4AwQT5U0Qaz1?WpHP(0 z+wW;dH-%P&M7U07=C7bKBP^Tw4zw|iN#NtYfp`zk^W{Sfm_i2T=vg)xK_)rlw?ku9 zaNdVDxvt$ZXO_W4CZh{4DMd1-vIZJAqW<1IFOw(E`or>_2XEy?C}+o zfaNh#A!%d4v5kyoTj_zpK8Qv&2TwCe5EDzKbUHbB0kZ32U2H@Qg_B%N1lQPb#K7SV^W3lUER@97wC^D-&$L^mag|%9vaxQ>X)(D!Jys zbAkh!BiBEsDaO?lJTnN@DV{zQf{V(78vUOcpU3DMkq85Ex%y>t4Ej#Mco%484J}^2 znOv;X{})!I7)Y&%>J{iyH-Jh=7?sr^=*X88^PeBRnjmTe>*6+>1N^%?6wL;8wAi7Xf{S;^nevBZ8 zE^E#kgqR4_7XTv(45jvG9K++9t1%&T_ENTE;{}`R5ErsWkzo@fLu0@Sf~BB+4o17k zcW?s7;_q~S+Ss%7}@@4|-{9>o8g_+w(>xibr~v-9DzYxN7d z<@&XuQVA!}!a9+2pt&);IU^KTrJ-{0wEQACsq zQ>n?Mu24h(zlJbYS8xzS+3bQHfzt-=A@Fa<{};hLm7;6psyX&>csI0N^mfozJi%j? z#rTf#2r%)j#LdL5EjPC;CiL;hjp$OWWj@@pR!?Oda<_vE*t(a{cj2EJyg{w5N`V%L z{H{WjU#qZx^_+ht-i3c$4&^rN0S$0_AIizeeQi%f9d^o)(FNkx!XuT!Us{!aO?dl9 zFxuS#8T>V=duq1l)`goFX3rLWe0Tr7LmwXc{gDUXo8Ncp!R7hb>G|+!wpqNxvkyHB zvmkcFu#sY&Vo3g^5$`>3c%#4uZ$NOl2SR`E;>BW!THYwNi^@62wm`y5IN@c;Kmxl? zGYNR7=Cbg8QR#D>3>X##)3cbdX$a|WnZ^XbtmTEVSvY@9?~^!$18Y_rtnU?^;;)r` z)UO|_=tpz=V<5KhsNR6TsBHL^m`W9^Q>kHxKRa{{vvhc@#D%tG+EXy>KT!Llqo{RE%%RtsbR|$!Ch3@uTw^6+(Tc7-sN>Z3BSNqbeLX% g7Z__W=6@uMqWCvq+vh_4KSHmG;u-O=zzR0?-%E?~W=LvEBiVF&0bbrY_kHd;-#O>rzxVk(45Yz%HU4x9!+ee(RuYT?yY{D0SYTvE zW)n=3onl!mI}(m52mEshF3C^vNnuJb>CPz^(D{UzbWgci#=)FsWZ?!QI~D#uXN)=J zp;Q-8Ma5I2dMVWnR8Ir7g;KphZE2wTDAfnl)&^>8#u4&=fs=$--5W`z(pp4|rBmue z$f0w`#*gc*k;|$^B2g`=XftUUnt8?(>8o?<=}1zE8p|_sbFJ*!D10w43NyvZ4D6Ym z;$)}nlDQkg6t4)fDDyY?`)1E6r|gE^@>4EYiAU$g(^|G?JfbR6G!ao%X*x|LV?OC} zT2qH6qUJQTKbGOguAQP&=GYt)HV0-Ht=2Nm*NC-3&T*eLKA=ynWrn%OPK0>fGnhLT{$CW4)E>rFet0Za`!&frN;b@vD!*qwU8C?vAW2u-H4rg0{A4AqxD@>qv zL3W*~cJ~+foBowR*TSLVp;E9C*j3_N8NRgBT z=L)ASO_Ov|(qlCsTu%vf(MB`eqVN+25(>69YHrlnNc z94MK7SAmrq%a){xdrXKUe%QRuz##$0Q5GctbzsSR4GF37Gk ztL==tg#@4+HRTA341;W@{GNcq0+VCnNVTQ7!$yG5I{+8Z8vEN4)Hr^`Pr^PXvf_Xt zn1`ORA&^0LI84OL zacH?MRBj6`Z5~`4crg0ukxz~+jU0dU(vtt=l6aDCm>>p*ctiHNSV|+Cfbh%%B`HTw z>wqllNzD$Wj_JqU9v^7QP>C`1VO+hotX7u26)UE>%+q&qLrbFs{N4NV1C8#T<0Ca?p2X- z2A?`SLdzNlrBy7gce27M)))Z$M&VaW=7F(mIGqjKc-aB?#b>yXpt}u>0Kv)j8^uIM zGDHZl47F^dhHcPQ+z*wVTXYhwCRfemrH)@RiFmBM-WE%JmCoS@Y`Jr! z;9K#w7V;HuUupAW?+evH$BMsgestlL;wuYBibqO^DuI_O{*fhdgdXn%kzkyA0+lwh z1Ih%&gP;}|@?;aG-B^JcwpCI3QIv+(d0yCW2n<$!J%Kq*z;vxvf&pX$g?^pLanY9k>RpA3z3!%`5LOjU zcVi9x)J20DlfgxZWfd4@qdt&*IF$D?9HVJ;bT|FL*1FTMnL3Zt=mH%}MV_L{(A20m zH9^{_H!nha^^YL?N_z98y|du02HOknYWt3-jNl$DoC0-1C*vo9(zk!yQwi)VoTvsn z7bc1m%fY?n;NHbcAHRL??Mm>~!pW*X_{Z7zv$tNW_;*%&x8FT``|P}XW$X6Rt~*zY z-c>IQx7x~V>RA%I8x)ZY!RqYjjmsvOA%|)zcV?Y$6?zPn!^*Fva+?fJ9Z!>#LR8ee z;|2viOjGBGG7WVSbta_rtdfeQuD}-oZ^!6H9nSlFM6+m1ww_0G9+}Sq7Hn%4%6VVTR|A&6%=rLBn(!1rw*;!b?aO^Hm-}9R zAXoZcEljLBnV#Olx2oL(PZ@`AXVE$Dm>);n8!L`22X>bOyB8gc?dcJ zM^BeWPyfR4i}BA!FI0ouN-vdz{l#ys3eb1e!))nZI9ohh>RD9F+ega55ekg+*@}OA zweN*_PaP1q!LnCh_-cT77`Q_)JBGZne30|&e3u1?Sh)cZc_2Vy*RbXXcDEHm@&Yr> zg7fN|;vp#Kz=;(g7>D2-f^v{~_=gtP*eMabKCkXM4>4&nat#49W0t|SjgaUJ1vHU)QIV#=BDV*u*~5GQAPfQ1wV(9F?*lSF+T*Qg)0Hhf(n+&IUv|KfrDW( zj1kFjAi{IA3ZYSjF3`n=1Vdp^yUFXAA>xr9$W-*RQN*ts0*mCV5wz*y35Thkw38P-&Xd6Wq9oG z|8<8{3VyWh!)*`xKOOjFV0mP`JTm_1rAJC-e)oen!2@saU2fl1Zr@eXKKlNL-~So+@YG**sKHqXP@ zHL0(6h7p_GU|8cd`egAY9&JvrTADW}&YTlG(J4SN3`(2$yVhP%v*UQVWJ}H0qt$%i zy60;l65zu(p;t595`nWE#e&-Pf8MRtyx<(4b6cZ;_P=HLnKIXW4LDBT)4-k-}` zkHwv@$;aROKk(7>B|IDcQl2^M_^H*<+{Uwurf=>tvG6DyCqY0wrNeFJ^retbZvpQa zuZzPN;KMbmf?xwT+k_(EX7s@6MV@u7Z9riCis0Kg0ADsYCf5M>g;afX6hOs3ZZhEERC2_wLqx+^_9i|-ODx+ZnxGocS>+USg!h+BXP z^(aNXs&~`;u|FKU6x1)vAi>V% z;9xm8xH$Upk$XpIoWIh$_3oM5XG(9}xv+Sw+&fe_U+v!gl;M4Q=bhEAzPnwwyYBQr zG0?Rf*ijDbD4qD|!iN`rI{C2uFW&qtaID(B_3pmg`xc!Kgoo@W?uRc|x{u8tU+LXe z5=!cw3&oSGZWsz2>0npk9JuBkowxe$4&NRww}%R+abY_@IM=waZ&ZT&3nzc;MVJF(&6Q+k+V@nB{!q8-gw-GhrHG5)7JkH;TUzaaw-3 zDf;g~bvD>w^=n%DW2jN_VemTh1SI`n@nDJn$p4|g9C&d#aG)GGP!Owafq8i$T}+qT zb{1UK{bN6yd=&h<-oNRs9GZB%e`5Z`!nxwPQb#!$S`H3B4h}C}dZXZ8@dxL>Q*xKL zgev~QC2^372E1rY!c-F>rvVBS0B8h3OEiqYmk%OA22URKhmv|D8LD*tGQ5=0*Aas$ zd_&XiH~?jpyXh@9eIG#|j6x522gmc7k3~@>kqC$3g@^_-Xu6LYZqo#|$Lt;WsraVy zn;Xo(IXFT55069WSrdDOv+UZzE}`o`4+z2@x?9>ldZDbYGw`>5_zcVLnGgIsga34|{{x<{qFMj| literal 0 HcmV?d00001 diff --git a/src/blokus_gym/core/__pycache__/game.cpython-312.pyc b/src/blokus_gym/core/__pycache__/game.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f633397d7c2d1efa627d29766987e98bc6ef01cb GIT binary patch literal 19236 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 literal 0 HcmV?d00001 diff --git a/src/blokus_gym/core/__pycache__/pieces.cpython-312.pyc b/src/blokus_gym/core/__pycache__/pieces.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b6c7d6174eeb9752b1ee0690a4113cb4b30377e GIT binary patch literal 12747 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 hR`Jq)5Ys*(jFgVPyub;(@W9i~T4 literal 0 HcmV?d00001 diff --git a/src/blokus_gym/core/board.py b/src/blokus_gym/core/board.py new file mode 100644 index 0000000..5dccba7 --- /dev/null +++ b/src/blokus_gym/core/board.py @@ -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%})" diff --git a/src/blokus_gym/core/bots.py b/src/blokus_gym/core/bots.py new file mode 100644 index 0000000..27901b4 --- /dev/null +++ b/src/blokus_gym/core/bots.py @@ -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) diff --git a/src/blokus_gym/core/game.py b/src/blokus_gym/core/game.py new file mode 100644 index 0000000..27cb8ed --- /dev/null +++ b/src/blokus_gym/core/game.py @@ -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 diff --git a/src/blokus_gym/core/pieces.py b/src/blokus_gym/core/pieces.py new file mode 100644 index 0000000..51895c6 --- /dev/null +++ b/src/blokus_gym/core/pieces.py @@ -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})" diff --git a/src/blokus_gym/envs/__init__.py b/src/blokus_gym/envs/__init__.py new file mode 100644 index 0000000..e7fd5e2 --- /dev/null +++ b/src/blokus_gym/envs/__init__.py @@ -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() diff --git a/src/blokus_gym/envs/__pycache__/__init__.cpython-312.pyc b/src/blokus_gym/envs/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6083fcc5b86e27ddf632daecade158dddce65c49 GIT binary patch literal 1885 zcma)6O>7%Q6rNeH?e+SgRjcE@n&}!qEnoahP*>#Go zL@E*zmr96U;LuY!p>hCmfGa&AaRLO2MyZO_lW$H^4)nyEwVk-G5FTls-n@D5&3oUx z_kKyIRRrta9e?es1VWF+VYH+nvrmBe0ue;8g>0;0Thb&uqD5?3ld+IXR@9DZF*~ls zZADW;S;R`%s-}j#Y$ffKmcpooNc0}o(j@i?(lQdFnft>>FzUoBN>7|BGEcn4C?%Z> zZoqQ`cEM#1WxkN8OQvJm#z%rrtQZb)ZD1=AuGX(z(cihaxUksZ=}Xs^FJ4(09kY#h z=a=jAEA^qEf_dVC<*v8=MP~yJg~4H|Z3X6hi#mZ2Czt?-B?SR+5Q__<2oe({B8V(V z6i5^H!4fv&djA0qvBQ87+CrOXO-Lsei4d7YNh}b)!bReDBSiToaepYm|7EFyWs;-< z5znDzdJ4T9h zP5ilZyCj4D77jBlOz~=Q5~CnB@=sp4D^G=1X%ejoAJ5>{k(v??W4wtfN4sZNXv_2i z%F2dil|4oYy^RU?}5|4lZ$DS|ZFPERzYah5at=UEp)aq`tEq=kTdCXm-O<-0- zv}-X^iB3E9H9V^ ze;Pito+5jd5)EJqh9LA1KFO0gHbDox9Nm literal 0 HcmV?d00001 diff --git a/src/blokus_gym/envs/__pycache__/blokus_env.cpython-312.pyc b/src/blokus_gym/envs/__pycache__/blokus_env.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..317c7b0e6ca2bb750b6635bb4d858acb6901c3f3 GIT binary patch literal 16344 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 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) diff --git a/src/blokus_gym/envs/multiagent.py b/src/blokus_gym/envs/multiagent.py new file mode 100644 index 0000000..670f811 --- /dev/null +++ b/src/blokus_gym/envs/multiagent.py @@ -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 diff --git a/src/blokus_gym/utils/__init__.py b/src/blokus_gym/utils/__init__.py new file mode 100644 index 0000000..0d8ff89 --- /dev/null +++ b/src/blokus_gym/utils/__init__.py @@ -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", +] diff --git a/src/blokus_gym/utils/__pycache__/__init__.cpython-312.pyc b/src/blokus_gym/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..415c60697ef81e28b4ab7d9c3b3a63d777299d2a GIT binary patch literal 413 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 Y9UG=rx@BX4-cPrDJxa6mK2_O&0JUf>WdHyG literal 0 HcmV?d00001 diff --git a/src/blokus_gym/utils/render.py b/src/blokus_gym/utils/render.py new file mode 100644 index 0000000..ff8ccb6 --- /dev/null +++ b/src/blokus_gym/utils/render.py @@ -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)) diff --git a/src/blokus_gym/wrappers/__init__.py b/src/blokus_gym/wrappers/__init__.py new file mode 100644 index 0000000..27085c7 --- /dev/null +++ b/src/blokus_gym/wrappers/__init__.py @@ -0,0 +1,3 @@ +from blokus_gym.wrappers.action_mask import ActionMaskWrapper + +__all__ = ["ActionMaskWrapper"] diff --git a/src/blokus_gym/wrappers/__pycache__/__init__.cpython-312.pyc b/src/blokus_gym/wrappers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3d7079950d82c84e00587346b15d4b4c69a5d968 GIT binary patch literal 273 zcmX@j%ge<81R@iQv*LmDV-N=hn4pZ$VnD`ph7^Vr#vF!R#wbQch7_iB#weyrW=)ot zAVr#tw*(!NOEUBGd=rba!;2CN3Q~)Tn1M2Wn#{N4lXCL2ON-;vD|7YAA#%lfiD2dN zxj^+`g}2z_;}dgo;^S8`d2KczG$)vkyWXfPuX7YhK156p~=jCUDq RA8^ZE;F51-FJcD@0sw?pO;-ZN*X|PF?+HMNLq`ETc%0(AVx`9XonP{5SZtO~fHpW%YotgJ|0xsG`2RQfK zd(S=h{CwwJeyy%fAo#xj(iv-rBlLhyik}cN%=&x4%pnbFjDbuh!!VR*4K~BU$QisT zWQ1UiH)3Wy6F0?-$RHNIi!@;dX)!D=a-ptF#T1*2Kctf+<5wz{WxI;2+m@4tdCbWv zDt3~r&kb1Pe*8nC@LsxrLB!4Llp z3ns7`L5t0xOl%fSGMV^Ulob8iV=BG(wBlThu2Fts5sG-p9aUV(w8yanES8i&STaFc z$`ad@bSrBEaqVE+9(FJp4;+yKA5w{g1mC$)-I3ro@8E3Skg_(B&J2PA#l#afxkz=x zHFz?IRd>rhpnDigIfb}^E<3yBst%kQNp(tRU1iw7UB?v%8@h#^BhrNKj!J6Yalz7@ zZRo0woz6hZnL$Gz?v${rrjn77$FeRa(gcmQZjE$qxn&ruwh3`iq!T)rNp<%Ix~PX; zQd^nxZN1@XqL1j7Vn}6MN=CuYtju5>(D0RWY*+(tMR_=^doCp@(U4lkU>*0|KSp}8Zu z&F&Tr9dPPJL5N3Z2%SC&Hd6#U*xjTIWG=LoN9bco+AkfCf)pTdON?kHR6uX(s{jrv zg<1E3`HGoCZfWZ>@1ZZCv^{2#2^SgQ$M}*!i7BwK9xF{t%$QhWOH6@tD~pq{sv@^B zdTiPLD;w)YPJ^oTxGdwoT3oroEk-ucDlXdUA-Uw|6iNzdLZ=U@Cku=(Xzo-FFR*@P z8S8>pMwPnI`P`-XubR?WH7NMs2JS_m9J8n<TyO*j1tSym0jab(${ot5V7()0JbN1?d7O~rM)PuObS z#Fp!H=ZHN9Ey3vy(cSG3HDW2yyNlTEE^{oEn1l~_kyTjebOo49u!^rJN7078Q`KW^uO{;oz~ zgJ%IDcxgg-{sGJ!^3WKJo4>KB)t9e(jK|(!79$7@!C2r5yvL74Z&kUh#=7*L;M)$e zFS#{%4~yrb)7t>rErA$-8rf4E3xdaa0)?%|XIXe|0H*su6{(?w)(NQwk`&2q7Ru8xvjR5(T&DKAmd3dH9CW>rwOJ7aT^EXGXr0>@CU#^(tX3woPG|i1(9lch#G5q7` z!st!+_T;U})tVKq&}`+}zP6ihF4w$$r*5fcU`ZSxdqMdN zJ*kxRzhLLzCB0bvLy+bIOH*DD3NbIXZQ7atDeY`A-NmsG_gF8kagcV-<2}KPWd%q+ zpMFW+dB?>$asZ-14pMT6l1*6`gRJ|pu+m98i1;yrClsRj33#_ncxPQGvkI5wEtRsQ zgQ}rOb7-Fq08zrkYp^oiuoV;3k}i5g1p<&b(sTRht)sX5ZuQ-1xO?Q(viLgS;rx}% z_1*I){u3%1era0z;FG0KwWS(uNz?*#B-sG}gw`(nM+Obqf`5#lAtz+nw6(lJ`6^ld zEUy^RPF$8XTb1RY)ddJA)Fr<<>a8j^3|USxBxrTC<-8LX0}TbCg#I}akY_XKF596R(u!Db*%8@Q={y474qvP&2L3=i*zTqy6L!m{NNk+9)1z48z<>> 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 diff --git a/tests/__pycache__/test_board.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_board.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5f781db3065467de033a78ebcb655ce1ff36a543 GIT binary patch literal 26423 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_imports.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_imports.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..353d630d97234a9021556319ee804de686daeb9b GIT binary patch literal 11518 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 SMiLtb4D#%Qu6@M&Q~v?O4t?qX literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_pieces.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_pieces.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..39727a8a43e02f9a4377bc742c539bb82e4125e3 GIT binary patch literal 18987 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 literal 0 HcmV?d00001 diff --git a/tests/test_board.py b/tests/test_board.py new file mode 100644 index 0000000..3250fc8 --- /dev/null +++ b/tests/test_board.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import numpy as np + +from blokus_gym.core.board import Board + + +class TestBoard: + def test_board_creation(self): + board = Board(20) + assert board.size == 20 + assert board.grid.shape == (20, 20) + assert np.all(board.grid == 0) + + def test_in_bounds(self): + board = Board(20) + assert board.in_bounds(0, 0) + assert board.in_bounds(19, 19) + assert not board.in_bounds(-1, 0) + assert not board.in_bounds(20, 0) + assert not board.in_bounds(0, 20) + + def test_place_and_get(self): + board = Board(20) + cells = [(0, 0), (1, 0), (2, 0)] + board.place(1, cells) + assert board.get_cell(0, 0) == 1 + assert board.get_cell(1, 0) == 1 + assert board.get_cell(2, 0) == 1 + assert board.get_cell(3, 0) == 0 + + def test_has_overlap(self): + board = Board(20) + board.place(1, [(0, 0), (1, 0)]) + assert board.has_overlap([(0, 0), (1, 0)]) # overlap + assert not board.has_overlap([(2, 0), (3, 0)]) # no overlap + + def test_has_overlap_out_of_bounds(self): + board = Board(20) + assert board.has_overlap([(20, 0)]) # out of bounds + + def test_clear(self): + board = Board(20) + board.place(1, [(0, 0)]) + board.clear() + assert np.all(board.grid == 0) + + def test_get_player_squares(self): + board = Board(20) + board.place(1, [(0, 0), (1, 0)]) + squares = board.get_player_squares(1) + assert len(squares) == 2 + assert (0, 0) in squares + assert (1, 0) in squares + + def test_get_player_corners(self): + board = Board(20) + board.place(1, [(0, 0)]) + corners = board.get_player_corners(1) + assert (1, 1) in corners # diagonal + assert (0, 0) not in corners # occupied + + def test_is_full(self): + board = Board(2) + assert not board.is_full() + board.place(1, [(0, 0), (0, 1), (1, 0), (1, 1)]) + assert board.is_full() + + def test_copy(self): + board = Board(20) + board.place(1, [(0, 0)]) + board_copy = board.copy() + assert board_copy.get_cell(0, 0) == 1 + board_copy.place(2, [(1, 0)]) + assert board.get_cell(1, 0) == 0 # original unchanged + + def test_is_empty(self): + board = Board(20) + assert board.is_empty(0, 0) + board.place(1, [(0, 0)]) + assert not board.is_empty(0, 0) + + def test_coverage(self): + board = Board(2) + assert board.coverage() == 0.0 + board.place(1, [(0, 0)]) + assert board.coverage() == 0.25 + + def test_get_occupied(self): + board = Board(20) + board.place(1, [(0, 0), (1, 0)]) + board.place(2, [(5, 5)]) + occupied = board.get_occupied() + assert (0, 0) in occupied + assert (1, 0) in occupied + assert (5, 5) in occupied + assert (2, 0) not in occupied diff --git a/tests/test_envs.py b/tests/test_envs.py new file mode 100644 index 0000000..c4792b5 --- /dev/null +++ b/tests/test_envs.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import gymnasium as gym +import numpy as np + +from blokus_gym import ( + STANDARD_PIECES, + ActionMaskWrapper, + BlokusEnv, + BlokusMultiAgentEnv, + GreedyBot, +) + + +class TestBlokusEnv: + def test_env_creation(self): + env = BlokusEnv(num_players=4, board_size=20) + assert env.num_players == 4 + assert env.board_size == 20 + + def test_observation_space(self): + env = BlokusEnv(num_players=4, board_size=20) + obs, _ = env.reset(seed=42) + assert "board" in obs + assert "pieces" in obs + assert "corners" in obs + assert obs["board"].shape == (20, 20) + assert obs["pieces"].shape == (21,) + assert obs["corners"].shape == (20, 20) + + def test_action_space(self): + env = BlokusEnv(num_players=4, board_size=20) + assert env.action_space.shape == () + assert env.action_space.n > 0 + + def test_reset_returns_obs_and_info(self): + env = BlokusEnv(num_players=4, board_size=20) + obs, info = env.reset(seed=42) + assert isinstance(obs, dict) + assert isinstance(info, dict) + assert "action_mask" in info + + def test_action_mask_valid(self): + env = BlokusEnv(num_players=4, board_size=20) + obs, info = env.reset(seed=42) + mask = info["action_mask"] + assert mask.dtype == bool + assert mask.sum() > 0 # Should have valid moves + + def test_step_with_valid_action(self): + env = BlokusEnv(num_players=4, board_size=20) + obs, info = env.reset(seed=42) + valid_actions = np.where(info["action_mask"])[0] + action = valid_actions[0] + obs, reward, terminated, truncated, info = env.step(action) + assert isinstance(reward, float) + assert isinstance(terminated, bool) + assert isinstance(truncated, bool) + + def test_step_with_invalid_action(self): + env = BlokusEnv(num_players=4, board_size=20) + obs, info = env.reset(seed=42) + # Use an action that's likely invalid + obs, reward, terminated, truncated, info = env.step(env.action_space.n - 1) + assert reward < 0 # Penalty for invalid action + assert terminated # Should end episode + + def test_two_player_env(self): + env = BlokusEnv(num_players=2, board_size=14) + obs, info = env.reset(seed=42) + assert env.num_players == 2 + + def test_three_player_env(self): + env = BlokusEnv(num_players=3, board_size=20) + obs, info = env.reset(seed=42) + assert env.num_players == 3 + + def test_custom_pieces(self): + custom = [p for p in STANDARD_PIECES if p.size < 5] + env = BlokusEnv(num_players=2, board_size=10, pieces=custom) + obs, info = env.reset(seed=42) + assert obs["pieces"].shape == (len(custom),) + + def test_greedy_bot_opponent(self): + env = BlokusEnv(num_players=2, board_size=7, bot_type=GreedyBot) + obs, info = env.reset(seed=42) + assert env.bots[1] is not None + + def test_game_over(self): + env = BlokusEnv(num_players=2, board_size=5, max_steps=10) + obs, info = env.reset(seed=42) + terminated = False + truncated = False + steps = 0 + while not (terminated or truncated) and steps < 50: + valid = np.where(info["action_mask"])[0] + if len(valid) == 0: + break + obs, reward, terminated, truncated, info = env.step(valid[0]) + steps += 1 + + def test_seed_reproducibility(self): + env1 = BlokusEnv(num_players=2, board_size=7) + env2 = BlokusEnv(num_players=2, board_size=7) + obs1, _ = env1.reset(seed=42) + obs2, _ = env2.reset(seed=42) + np.testing.assert_array_equal(obs1["board"], obs2["board"]) + + def test_render_text(self): + env = BlokusEnv(num_players=2, board_size=7, render_mode="ansi") + obs, info = env.reset(seed=42) + result = env.render() + assert isinstance(result, str) + + +class TestActionMaskWrapper: + def test_wrapper_creation(self): + env = BlokusEnv(num_players=2, board_size=7) + env = ActionMaskWrapper(env) + obs, info = env.reset(seed=42) + assert "observation" in obs + assert "action_mask" in obs + + def test_wrapper_step(self): + env = BlokusEnv(num_players=2, board_size=7) + env = ActionMaskWrapper(env) + obs, info = env.reset(seed=42) + valid = np.where(obs["action_mask"])[0] + obs, reward, terminated, truncated, info = env.step(valid[0]) + assert "observation" in obs + assert "action_mask" in obs + + +class TestMultiAgentEnv: + def test_env_creation(self): + env = BlokusMultiAgentEnv(num_players=2, board_size=7) + assert env.num_players == 2 + assert len(env.agents) == 2 + + def test_reset(self): + env = BlokusMultiAgentEnv(num_players=2, board_size=7) + obs, info = env.reset(seed=42) + assert "player_0" in obs + + def test_step(self): + env = BlokusMultiAgentEnv(num_players=2, board_size=7) + obs, info = env.reset(seed=42) + mask = env.get_action_mask("player_0") + valid = np.where(mask)[0] + obs, rewards, terminations, truncations, infos = env.step(valid[0]) + assert isinstance(rewards, dict) + assert isinstance(terminations, dict) + + def test_observation_space(self): + env = BlokusMultiAgentEnv(num_players=2, board_size=7) + obs, _ = env.reset(seed=42) + for agent in env.agents: + if agent in obs: + assert "board" in obs[agent] + + def test_action_space(self): + env = BlokusMultiAgentEnv(num_players=2, board_size=7) + env.reset(seed=42) + space = env.action_space("player_0") + assert space.n > 0 + + def test_four_player(self): + env = BlokusMultiAgentEnv(num_players=4, board_size=7) + obs, info = env.reset(seed=42) + assert len(env.agents) == 4 + + +class TestRegisteredEnvs: + def test_blokus_v0(self): + env = gym.make("Blokus-v0") + obs, info = env.reset(seed=42) + assert obs["board"].shape == (20, 20) + + def test_blokus_duo_v0(self): + env = gym.make("BlokusDuo-v0") + obs, info = env.reset(seed=42) + assert obs["board"].shape == (14, 14) + + def test_blokus_junior_v0(self): + env = gym.make("BlokusJunior-v0") + obs, info = env.reset(seed=42) + assert obs["board"].shape == (14, 14) + + def test_blokus_simple_v0(self): + env = gym.make("BlokusSimple-v0") + obs, info = env.reset(seed=42) + assert obs["board"].shape == (7, 7) + + def test_blokus_greedy_v0(self): + env = gym.make("BlokusGreedy-v0") + obs, info = env.reset(seed=42) + assert obs["board"].shape == (20, 20) diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..19e27f2 --- /dev/null +++ b/tests/test_game.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import numpy as np + +from blokus_gym.core.bots import GreedyBot, GreedyCornersBot, RandomBot +from blokus_gym.core.game import BlokusGame + + +class TestBlokusGame: + def test_game_creation(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + assert game.board_size == 20 + assert game.num_players == 4 + assert game.current_player == 0 + assert len(game.players) == 4 + + def test_reset(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + assert game.current_player == 0 + for player in game.players: + assert len(player.available_pieces) == 21 + + def test_action_space_size(self): + game = BlokusGame(board_size=20, num_players=4) + assert game.num_actions > 0 + + def test_valid_actions_initial(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + valid = game.get_valid_actions(0) + assert np.any(valid) + + def test_play_first_move_corner(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + valid = game.get_valid_actions(0) + valid_indices = np.where(valid)[0] + action = valid_indices[0] + assert game.play_move(0, action) + assert game.board.grid.sum() > 0 + + def test_invalid_action(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + # Use an action that's likely invalid (very high index) + assert not game.play_move(0, game.num_actions - 1) + + def test_next_player(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + assert game.current_player == 0 + game.next_player() + assert game.current_player == 1 + game.next_player() + assert game.current_player == 2 + game.next_player() + assert game.current_player == 3 + game.next_player() + assert game.current_player == 0 + + def test_has_valid_moves(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + assert game.has_valid_moves(0) + + def test_is_game_over(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + assert not game.is_game_over() + + def test_get_scores(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + scores = game.get_scores() + assert len(scores) == 4 + for score in scores: + assert isinstance(score, (int, float)) + + def test_get_action_mask(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + mask = game.get_action_mask(0) + assert mask.shape == (game.num_actions,) + assert mask.dtype == bool + + def test_get_current_observation(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + obs = game.get_current_observation() + assert "board" in obs + assert "pieces" in obs + assert "corners" in obs + assert obs["board"].shape == (20, 20) + assert obs["pieces"].shape == (21,) + assert obs["corners"].shape == (20, 20) + + def test_two_player_game(self): + game = BlokusGame(board_size=14, num_players=2) + game.reset() + assert game.num_players == 2 + assert game.has_valid_moves(0) + + def test_three_player_game(self): + game = BlokusGame(board_size=20, num_players=3) + game.reset() + assert game.num_players == 3 + + def test_copy(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + game.play_move(0, 0) + game_copy = game.copy() + assert game_copy.current_player == game.current_player + + def test_play_multiple_moves(self): + game = BlokusGame(board_size=20, num_players=2) + game.reset() + # Play a few moves + for _ in range(5): + valid = game.get_valid_actions(game.current_player) + valid_indices = np.where(valid)[0] + if len(valid_indices) > 0: + game.play_move(game.current_player, valid_indices[0]) + game.next_player() + else: + break + + def test_get_winners_not_over(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + assert game.get_winners() is None + + def test_valid_move_first_corner_only(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + from blokus_gym.core.pieces import Move + # First move must be in corner (0, 0) for player 0 + move = Move(piece_id=0, orientation_id=0, x=0, y=0) + assert game.valid_move(0, move) + # Try a non-corner first move + move2 = Move(piece_id=0, orientation_id=0, x=5, y=5) + assert not game.valid_move(0, move2) + + +class TestBots: + def test_random_bot(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + bot = RandomBot(player_idx=0, seed=42) + valid = game.get_valid_actions(0) + action = bot.select_action(game, valid) + assert action is not None + assert valid[action] + + def test_greedy_bot(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + bot = GreedyBot(player_idx=0) + valid = game.get_valid_actions(0) + action = bot.select_action(game, valid) + assert action is not None + assert valid[action] + + def test_greedy_corners_bot(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + bot = GreedyCornersBot(player_idx=0) + valid = game.get_valid_actions(0) + action = bot.select_action(game, valid) + assert action is not None + assert valid[action] + + def test_bot_no_valid_moves(self): + game = BlokusGame(board_size=20, num_players=4) + game.reset() + bot = RandomBot(player_idx=0, seed=42) + valid = np.zeros(game.num_actions, dtype=bool) + action = bot.select_action(game, valid) + assert action is None + + def test_bots_play_game(self): + game = BlokusGame(board_size=7, num_players=2) + game.reset() + bots = [RandomBot(player_idx=i, seed=i) for i in range(2)] + steps = 0 + while not game.is_game_over() and steps < 100: + bot = bots[game.current_player] + valid = game.get_valid_actions(game.current_player) + action = bot.select_action(game, valid) + if action is not None: + game.play_move(game.current_player, action) + game.next_player() + steps += 1 + assert game.is_game_over() diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..e74bf56 --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,74 @@ +from __future__ import annotations + + +def test_package_import(): + import blokus_gym + assert blokus_gym.__version__ == "0.1.0" + + +def test_core_imports(): + from blokus_gym import BlokusGame, Board, Move + assert Board is not None + assert BlokusGame is not None + assert Move is not None + + +def test_env_imports(): + from blokus_gym import BlokusEnv, BlokusMultiAgentEnv + assert BlokusEnv is not None + assert BlokusMultiAgentEnv is not None + + +def test_wrapper_imports(): + from blokus_gym import ActionMaskWrapper + assert ActionMaskWrapper is not None + + +def test_piece_imports(): + from blokus_gym import ( + DUO_PIECES, + JUNIOR_PIECES, + STANDARD_PIECES, + Piece, + PieceSet, + ) + assert len(STANDARD_PIECES) == 21 + assert len(DUO_PIECES) > 0 + assert len(JUNIOR_PIECES) > 0 + assert Piece is not None + assert PieceSet is not None + + +def test_bot_imports(): + from blokus_gym import GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot + assert RandomBot is not None + assert GreedyBot is not None + assert GreedyCornersBot is not None + assert MinimaxBot is not None + + +def test_all_exports(): + import blokus_gym + expected = [ + "BlokusEnv", + "BlokusMultiAgentEnv", + "ActionMaskWrapper", + "Board", + "BlokusGame", + "Move", + "Piece", + "PieceOrientation", + "PieceSet", + "STANDARD_PIECES", + "DUO_PIECES", + "JUNIOR_PIECES", + "generate_orientations", + "Bot", + "RandomBot", + "GreedyBot", + "GreedyCornersBot", + "MinimaxBot", + "__version__", + ] + for name in expected: + assert hasattr(blokus_gym, name), f"Missing export: {name}" diff --git a/tests/test_pieces.py b/tests/test_pieces.py new file mode 100644 index 0000000..b142677 --- /dev/null +++ b/tests/test_pieces.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from blokus_gym.core.pieces import ( + STANDARD_PIECES, + Piece, + PieceOrientation, + PieceSet, + generate_orientations, +) + + +class TestPiece: + def test_piece_creation(self): + piece = Piece(name="F", squares=frozenset([(0, 0), (1, 0), (1, 1), (2, 1)])) + assert piece.name == "F" + assert piece.size == 4 + assert len(piece.squares) == 4 + + def test_piece_size(self): + piece = Piece(name="I4", squares=frozenset([(0, 0), (1, 0), (2, 0), (3, 0)])) + assert piece.size == 4 + + def test_standard_pieces_count(self): + assert len(STANDARD_PIECES) == 21 + + def test_standard_pieces_total_squares(self): + total = sum(p.size for p in STANDARD_PIECES) + assert total == 89 + + def test_piece_orientations_via_pieceset(self): + piece_set = PieceSet(STANDARD_PIECES) + orientations = piece_set.get_orientations(0) + assert len(orientations) > 0 + for orient in orientations: + assert isinstance(orient, PieceOrientation) + + +class TestPieceSet: + def test_piece_set_creation(self): + piece_set = PieceSet(STANDARD_PIECES) + assert piece_set.num_pieces == 21 + assert len(piece_set.pieces) == 21 + + def test_piece_set_custom(self): + custom = [p for p in STANDARD_PIECES if p.size <= 3] + piece_set = PieceSet(custom) + assert piece_set.num_pieces == len(custom) + + def test_piece_set_get_piece_id(self): + piece_set = PieceSet(STANDARD_PIECES) + piece_id = piece_set.get_piece_id("F5") + assert piece_set.get_piece(piece_id).name == "F5" + + def test_piece_set_get_orientations(self): + piece_set = PieceSet(STANDARD_PIECES) + for i in range(piece_set.num_pieces): + orientations = piece_set.get_orientations(i) + assert len(orientations) > 0 + + +class TestGenerateOrientations: + def test_square_piece_one_orientation(self): + piece = Piece("O4", frozenset([(0, 0), (0, 1), (1, 0), (1, 1)])) + orientations = generate_orientations(piece) + assert len(orientations) == 1 + + def test_line_piece_two_orientations(self): + piece = Piece("I3", frozenset([(0, 0), (1, 0), (2, 0)])) + orientations = generate_orientations(piece) + assert len(orientations) == 2 + + def test_L_piece_four_orientations(self): + piece = Piece("L3", frozenset([(0, 0), (0, 1), (1, 0)])) + orientations = generate_orientations(piece) + assert len(orientations) == 4 + + def test_all_orientations_unique(self): + piece_set = PieceSet(STANDARD_PIECES) + for piece_id in range(piece_set.num_pieces): + orientations = piece_set.get_orientations(piece_id) + unique_cells = set() + for orient in orientations: + key = tuple(sorted(orient.squares)) + assert key not in unique_cells, f"Duplicate orientation in piece {piece_id}" + unique_cells.add(key)