From e3cda7dfbfc3dad4a12a57d9d4b2e393c381ed9c Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Thu, 4 Dec 2025 17:36:05 -0800 Subject: [PATCH 01/11] Add Minesweeper environment core implementation Implement a Minesweeper game environment for reinforcement learning agents. The environment features a grid-based game where agents must reveal all non-mine cells without triggering mines. Key features: - 5x5 grid with configurable mine placement - Two action types: reveal cells and place/remove flags - Number indicators showing adjacent mine counts - Recursive cell reveal for cells with zero adjacent mines - Reward system based on game progress and outcomes - Game status tracking (ONGOING, WON, LOST) Components: - Environment server with FastAPI integration - Game logic in MinesweeperEnvironment class - HTTP client for remote environment access - Pydantic models for actions and observations - Package configuration with dependencies --- src/envs/minesweeper_env/__init__.py | 13 + src/envs/minesweeper_env/client.py | 105 ++++++ src/envs/minesweeper_env/models.py | 126 +++++++ src/envs/minesweeper_env/openenv.yaml | 6 + src/envs/minesweeper_env/pyproject.toml | 32 ++ src/envs/minesweeper_env/server/__init__.py | 12 + src/envs/minesweeper_env/server/app.py | 85 +++++ .../server/minesweeper_environment.py | 322 ++++++++++++++++++ 8 files changed, 701 insertions(+) create mode 100644 src/envs/minesweeper_env/__init__.py create mode 100644 src/envs/minesweeper_env/client.py create mode 100644 src/envs/minesweeper_env/models.py create mode 100644 src/envs/minesweeper_env/openenv.yaml create mode 100644 src/envs/minesweeper_env/pyproject.toml create mode 100644 src/envs/minesweeper_env/server/__init__.py create mode 100644 src/envs/minesweeper_env/server/app.py create mode 100644 src/envs/minesweeper_env/server/minesweeper_environment.py diff --git a/src/envs/minesweeper_env/__init__.py b/src/envs/minesweeper_env/__init__.py new file mode 100644 index 000000000..4048c243e --- /dev/null +++ b/src/envs/minesweeper_env/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Minesweeper Environment - A simple test environment for HTTP server.""" + +from .client import MinesweeperEnv +from .models import MinesweeperAction, MinesweeperObservation, GameStatus + +__all__ = ["MinesweeperAction", "MinesweeperObservation", "MinesweeperEnv", "GameStatus"] + diff --git a/src/envs/minesweeper_env/client.py b/src/envs/minesweeper_env/client.py new file mode 100644 index 000000000..31e101e04 --- /dev/null +++ b/src/envs/minesweeper_env/client.py @@ -0,0 +1,105 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Minesweeper Environment HTTP Client. + +This module provides the client for connecting to a Minesweeper Environment server +over HTTP. +""" + +from typing import Any, Dict + +from openenv_core.client_types import StepResult +from openenv_core.env_server.types import State +from openenv_core.http_env_client import HTTPEnvClient + +from .models import MinesweeperAction, MinesweeperObservation + + +class MinesweeperEnv(HTTPEnvClient[MinesweeperAction, MinesweeperObservation]): + """ + HTTP client for the Minesweeper Environment. + + This client connects to a MinesweeperEnvironment HTTP server and provides + methods to interact with it: reset(), step(), and state access. + + Example: + >>> # Connect to a running server + >>> client = MinesweeperEnv(base_url="http://localhost:8000") + >>> result = client.reset() + >>> print(result.observation.echoed_message) + >>> + >>> # Send a message + >>> result = client.step(MinesweeperAction(message="Hello!")) + >>> print(result.observation.echoed_message) + >>> print(result.reward) + + Example with Docker: + >>> # Automatically start container and connect + >>> client = MinesweeperEnv.from_docker_image("minesweeper_env-env:latest") + >>> result = client.reset() + >>> result = client.step(MinesweeperAction(message="Test")) + """ + + def _step_payload(self, action: MinesweeperAction) -> Dict: + """ + Convert MinesweeperAction to JSON payload for step request. + + Args: + action: MinesweeperAction instance + + Returns: + Dictionary representation suitable for JSON encoding + """ + return { + "row": action.row, + "col": action.col, + "action_type": action.action_type, + } + + def _parse_result(self, payload: Dict) -> StepResult[MinesweeperObservation]: + """ + Parse server response into StepResult[MinesweeperObservation]. + + Args: + payload: JSON response from server + + Returns: + StepResult with MinesweeperObservation + """ + obs_data = payload.get("observation", {}) + observation = MinesweeperObservation( + board=obs_data.get("board", []), + num_mines=obs_data.get("num_mines", 0), + flags_placed=obs_data.get("flags_placed", 0), + cells_revealed=obs_data.get("cells_revealed", 0), + game_status=obs_data.get("game_status", "ongoing"), + done=payload.get("done", False), + reward=payload.get("reward"), + metadata=obs_data.get("metadata", {}), + ) + + return StepResult( + observation=observation, + reward=payload.get("reward"), + done=payload.get("done", False), + ) + + def _parse_state(self, payload: Dict) -> State: + """ + Parse server response into State object. + + Args: + payload: JSON response from /state endpoint + + Returns: + State object with episode_id and step_count + """ + return State( + episode_id=payload.get("episode_id"), + step_count=payload.get("step_count", 0), + ) diff --git a/src/envs/minesweeper_env/models.py b/src/envs/minesweeper_env/models.py new file mode 100644 index 000000000..15f65722c --- /dev/null +++ b/src/envs/minesweeper_env/models.py @@ -0,0 +1,126 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Data models for the Minesweeper Env Environment. + +The minesweeper_env environment is a simple test environment that echoes back messages. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import List, Any, Dict, Optional, Set, Tuple +from openenv_core.env_server.types import Action, Observation + +class GameStatus(Enum): + ONGOING = 0 + WON = 1 + LOST = 2 + +@dataclass(kw_only=True) +class MinesweeperAction(Action): + """Action for the Minesweeper environment Attributes: + row: Row index of the cell to act on (0-indexed). + col: Column index of the cell to act on (0-indexed). + action_type: Type of action - 'reveal' to uncover a cell, 'flag' to place a flag/remove a flag.""" + + row: int + col: int + action_type: str # 'reveal' or 'flag' + + +@dataclass(kw_only=True) +class MinesweeperObservation(Observation): + """Observation from the Minesweeper environment + This represents what the agent can see - a partial view of the board with hidden mine locations (unless revealed). + Attributes: + board: 2D list representing the current state of the board. Each cell can be: + - -1: unrevealed + - 0-8: number of adjacent mines (if revealed) + - 'F: flagged cell + - * : mine (only revealed if game is lost) + num_mines: Total number of mines on the board. + flags_placed: Number of flags currently placed by the agent. + cells_revealed: Number of cells that have been revealed so far. + game_status: Current status of the game - ongoing, won, or lost. + """ + + board: List[List[Any]] + num_mines: int + flags_placed: int + cells_revealed: int + game_status: GameStatus + + @property + def board_height(self) -> int: + """Height of the board (number of rows).""" + return len(self.board) + @property + def board_width(self) -> int: + """Width of the board (number of columns).""" + return len(self.board[0]) if self.board else 0 + +@dataclass(kw_only=True) +class MinesweeperState: + """State of the Minesweeper environment. + This represents the full internal state of the environment, including hidden information. + Attributes: + episode_id: Unique identifier for the current episode. + step_count: Number of steps taken in the current episode. + board_height: Height of the board (number of rows). + board_width: Width of the board (number of columns). + mine_locations: Set of (row, col) tuples indicating where mines are located. + revealed_cells: Set of (row, col) tuples indicating which cells have been revealed. + flags: Set of (row, col) tuples indicating where flags have been placed. + mine_counts: 2D list with counts of adjacent mines for each cell. + game_status: Current status of the game - ongoing, won, or lost. + """ + + episode_id: str + step_count: int + board_height: int + board_width: int + mine_locations: Set[Tuple[int, int]] + revealed_cells: Set[Tuple[int, int]] + flags: Set[Tuple[int, int]] + mine_counts: List[List[int]] + game_status: GameStatus + + def to_observation(self) -> MinesweeperObservation: + """Convert the full state to a partial observation for the agent. + Returns: + MinesweeperObservation representing the agent's view of the board. + """ + board = [] + for r in range(self.board_height): + row = [] + for c in range(self.board_width): + if (r, c) in self.revealed_cells: + if (r, c) in self.mine_locations: + cell_value = '*' # Revealed mine + else: + cell_value = self.mine_counts[r][c] # Number of adjacent mines + elif (r, c) in self.flags: + cell_value = 'F' # Flagged cell + else: + cell_value = -1 # Unrevealed cell + row.append(cell_value) + board.append(row) + + return MinesweeperObservation( + board=board, + num_mines=len(self.mine_locations), + flags_placed=len(self.flags), + cells_revealed=len(self.revealed_cells), + game_status=self.game_status, + done=self.game_status != GameStatus.ONGOING, + reward=0.0, + metadata={ + "episode_id": self.episode_id, + "step_count": self.step_count, + }, + ) + diff --git a/src/envs/minesweeper_env/openenv.yaml b/src/envs/minesweeper_env/openenv.yaml new file mode 100644 index 000000000..e8ea68e01 --- /dev/null +++ b/src/envs/minesweeper_env/openenv.yaml @@ -0,0 +1,6 @@ +spec_version: 1 +name: minesweeper +type: space +runtime: fastapi +app: server.app:app +port: 8000 \ No newline at end of file diff --git a/src/envs/minesweeper_env/pyproject.toml b/src/envs/minesweeper_env/pyproject.toml new file mode 100644 index 000000000..8a6c671c5 --- /dev/null +++ b/src/envs/minesweeper_env/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openenv-minesweeper" +version = "0.1.0" +description = "Minesweeper Environment for OpenEnv" +requires-python = ">=3.10" +dependencies = [ + "openenv-core>=0.1.0", + "fastapi>=0.115.0", + "uvicorn>=0.24.0", + "pydantic>=2.0.0", + "requests>=2.31.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", +] + +[project.scripts] +# Server entry point -enables running via: uv run --project . server +# or: python -m minesweeper.server.app +server = "minesweeper.server.app:main" + +[tool.setuptools] +include-package-data = true +packages = ["minesweeper", "minesweeper.server"] +package-dir = { "minesweeper" = ".", "minesweeper.server" = "server" } \ No newline at end of file diff --git a/src/envs/minesweeper_env/server/__init__.py b/src/envs/minesweeper_env/server/__init__.py new file mode 100644 index 000000000..36bb963e8 --- /dev/null +++ b/src/envs/minesweeper_env/server/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Minesweeper environment server components.""" + +from .minesweeper_environment import MinesweeperEnvironment + +__all__ = ["MinesweeperEnvironment"] + diff --git a/src/envs/minesweeper_env/server/app.py b/src/envs/minesweeper_env/server/app.py new file mode 100644 index 000000000..7afb6592f --- /dev/null +++ b/src/envs/minesweeper_env/server/app.py @@ -0,0 +1,85 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +FastAPI application for the Minesweeper Env Environment. + +This module creates an HTTP server that exposes the MinesweeperEnvironment +over HTTP endpoints, making it compatible with HTTPEnvClient. + +Usage: + # Development (with auto-reload): + uvicorn server.app:app --reload --host 0.0.0.0 --port 8000 + + # Production: + uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4 + + # Or run directly: + python -m server.app +""" + +try: + from openenv_core.env_server.http_server import create_app +except Exception as e: # pragma: no cover + raise ImportError( + "openenv_core is required for the web interface. Install dependencies with '\n" + " uv sync\n'" + ) from e + +from .minesweeper_environment import MinesweeperEnvironment +from ..models import MinesweeperAction, MinesweeperObservation + +# Create the environment instance +env = MinesweeperEnvironment() + +# Create the app with web interface and README integration +app = create_app( + env, + MinesweeperAction, + MinesweeperObservation, + env_name="minesweeper", +) + + +def main(host: str = "0.0.0.0", port: int = 8000): + """ + Entry point for direct execution via uv run or python -m. + + This function enables running the server without Docker: + uv run --project . server + uv run --project . server --port 8001 + python -m minesweeper_env.server.app + + Args: + host: Host address to bind to (default: "0.0.0.0") + port: Port number to listen on (default: 8000) + + For production deployments, consider using uvicorn directly with + multiple workers: + uvicorn minesweeper_env.server.app:app --workers 4 + """ + import uvicorn + + uvicorn.run(app, host=host, port=port) + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port number to listen on (default: 8000)" + ) + parser.add_argument( + "--host", + type=str, + default="0.0.0.0", + help="Host address to bind to (default: 0.0.0.0)" + ) + args = parser.parse_args() + main(port=args.port, host=args.host) diff --git a/src/envs/minesweeper_env/server/minesweeper_environment.py b/src/envs/minesweeper_env/server/minesweeper_environment.py new file mode 100644 index 000000000..f902842ee --- /dev/null +++ b/src/envs/minesweeper_env/server/minesweeper_environment.py @@ -0,0 +1,322 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Minesweeper Environment Implementation. + +A simple test environment that echoes back messages sent to it. +Perfect for testing HTTP server infrastructure. +""" +import random +from typing import Any, Dict, List, Optional, Set, Tuple +from uuid import uuid4 + +from ..models import ( + MinesweeperAction, + MinesweeperObservation, + GameStatus, + MinesweeperState, +) + +from openenv_core.env_server.interfaces import Environment +from openenv_core.env_server.types import State + + +class MinesweeperEnvironment(Environment): + """ + Minesweeper game environment implementation for Reinforcement Learning. + The environment consists of a grid with hidden mines. The agent can reveal cells or place flags. + The goal is to reveal all non-mine cells without triggering a mine. + The agent must: + - Reveal cells to uncover numbers indicating adjacent mines. + - Place flags on suspected mine locations. + The game ends when all non-mine cells are revealed (win) or a mine is revealed (loss). + + Observation encoding: + -1: unrevealed + 0-8: number of adjacent mines (if revealed) + 'F': flagged cell + '*': mine (only revealed if game is lost) + + Example: + >>> env = MinesweeperEnvironment(height=5, width=5, num_mines=5) + >>> obs = env.reset() + >>> action = MinesweeperAction(row=2, col=3, action_type='reveal') + """ + + def __init__(self, height: int = 5, width: int = 5, num_mines: int = 5): + """Initialize the minesweeper_env environment. + Args: + height: Height of the minesweeper board. + width: Width of the minesweeper board. + num_mines: Number of mines to place on the board. + """ + self.height = height + self.width = width + self.num_mines = num_mines + + self._state = State(episode_id=str(uuid4()), step_count=0) + self._reset_count = 0 + + # Internal game state + self._mine_positions: Set[Tuple[int, int]] = set() + self._revealed_cells: Set[Tuple[int, int]] = set() + self._flags_placed: Set[Tuple[int, int]] = set() + self._mine_counts: List[List[int]] = [] + self._game_status = GameStatus.ONGOING + + def reset(self) -> MinesweeperObservation: + """ + Reset the environment and starts a new game. + + Returns: + MinesweeperObservation with initial board state + """ + self._state = State(episode_id=str(uuid4()), step_count=0) + self._reset_count += 1 + + # Reset internal game state + self._revealed_cells.clear() + self._flags_placed.clear() + self._game_status = GameStatus.ONGOING + + # Place mines randomly + self._place_mines() + + # Compute mine counts for each cell + self._compute_mine_counts() + + return self._create_observation( + done=False, + reward=0.0, + ) + + def step(self, action: MinesweeperAction) -> MinesweeperObservation: # type: ignore[override] + """ + Execute a step in the environment by performing the given action. + + Args: + action: MinesweeperAction specifying row, col and action_type + + Returns: + MinesweeperObservation with updated board state and reward + """ + self._state.step_count += 1 + + row, col = action.row, action.col + + # Validate action + if not self._is_valid_position(row, col): + # Invalid action or game already over + return self._create_observation( + done=self._game_status != GameStatus.ONGOING, + reward=-0.1, + metadata={"error": "Invalid action"}, + ) + + # If game already over, no further actions allowed + if self._game_status != GameStatus.ONGOING: + return self._create_observation( + done=True, + reward=0.0, + metadata={"info": "Game already over"}, + ) + + reward = 0.0 + + if action.action_type == "reveal": + reward = self._reveal_cell(row, col) + elif action.action_type == "flag": + reward = self._toggle_flag(row, col) + else: + reward = -0.1 # Invalid action type + + self._check_win_condition() + + return self._create_observation( + done=self._game_status != GameStatus.ONGOING, + reward=reward, + ) + + def _place_mines(self) -> None: + """Randomly place mines on the board.""" + self._mine_positions.clear() + while len(self._mine_positions) < self.num_mines: + r = random.randint(0, self.height - 1) + c = random.randint(0, self.width - 1) + self._mine_positions.add((r, c)) + + def _compute_mine_counts(self) -> None: + """Compute the number of adjacent mines for each cell.""" + self._mine_counts = [[0 for _ in range(self.width)] for _ in range(self.height)] + for row in range(self.height): + for col in range(self.width): + if (row,col) not in self._mine_positions: + count = self._count_adjacent_mines(row, col) + self._mine_counts[row][col] = count + + def _count_adjacent_mines(self, row: int, col: int) -> int: + """Count the number of mines adjacent to the given cell.""" + count = 0 + for dr in [-1, 0, 1]: + for dc in [-1, 0, 1]: + if dr == 0 and dc == 0: + continue + r, c = row + dr, col + dc + if self._is_valid_position(r, c) and (r, c) in self._mine_positions: + count += 1 + return count + + def _reveal_cell(self, row: int, col: int) -> float: + """Reveal the cell at (row, col). Returns the reward for the action.""" + if (row, col) in self._revealed_cells or (row, col) in self._flags_placed: + return -0.05 # Penalty for revealing already revealed or flagged cell + + self._revealed_cells.add((row, col)) + + if (row, col) in self._mine_positions: + self._game_status = GameStatus.LOST + self._revealed_cells.add((row, col)) + return -10.0 # Penalty for hitting a mine + + # Reveal the cell and potentially adjacent cells if count is 0 + self._reveal_recursive(row, col) + + return 1.0 # Small reward for safe reveal + + def _reveal_recursive(self, row: int, col: int) -> None: + """Recursively reveal cells with 0 adjacent mines.""" + if not self._is_valid_position(row, col): + return + if (row, col) in self._revealed_cells or (row, col) in self._flags_placed: + return + + if (row, col) in self._mine_positions: + return + + self._revealed_cells.add((row, col)) + + if self._mine_counts[row][col] == 0: + for dr in [-1, 0, 1]: + for dc in [-1, 0, 1]: + if dr == 0 and dc == 0: + continue + self._reveal_recursive(row + dr, col + dc) + + def _toggle_flag(self, row: int, col: int) -> float: + """Toggle a flag on the cell at (row, col). Returns the reward for the action.""" + if (row, col) in self._revealed_cells: + return -0.05 # Penalty for flagging a revealed cell + + if (row, col) in self._flags_placed: + self._flags_placed.remove((row, col)) + return 0.0 # No penalty for removing a flag + else: + self._flags_placed.add((row, col)) + if (row, col) in self._mine_positions: + return 0.5 # Small reward for correctly flagging a mine + return 0.0 # No reward for flagging a non-mine cell + + def _check_win_condition(self) -> None: + """Check if the game has been won.""" + total_cells = self.height * self.width + revealed_count = len(self._revealed_cells) + if revealed_count == total_cells - self.num_mines: + self._game_status = GameStatus.WON + + def _is_valid_position(self, row: int, col: int) -> bool: + """Check if the given (row, col) is within board bounds.""" + return 0 <= row < self.height and 0 <= col < self.width + + def _create_observation( + self, + done: bool, + reward: Optional[float] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> MinesweeperObservation: + """Create the current observation of the board. + Args: + done: Whether the episode is done. + reward: Reward obtained from the last action. + metadata: Additional metadata to include. + Returns: + MinesweeperObservation representing the current board state. + """ + board = [] + for r in range(self.height): + row = [] + for c in range(self.width): + if (r, c) in self._revealed_cells: + if (r, c) in self._mine_positions: + row.append('*') + else: + row.append(self._mine_counts[r][c]) + elif (r, c) in self._flags_placed: + row.append('F') + else: + row.append(-1) + board.append(row) + + return MinesweeperObservation( + board=board, + num_mines=self.num_mines, + flags_placed=len(self._flags_placed), + cells_revealed=len(self._revealed_cells), + game_status=self._game_status, + done=done, + reward=reward, + metadata=metadata or {}, + ) + + @property + def state(self) -> State: + """ + Get the current environment state. + + Returns: + Current State with episode_id and step_count + """ + return self._state + + def get_full_state(self) -> MinesweeperState: + """ + Get the full internal state of the Minesweeper environment. + + Returns: + MinesweeperState representing the full internal state + """ + return MinesweeperState( + episode_id=self._state.episode_id, + step_count=self._state.step_count, + board_height=self.height, + board_width=self.width, + mine_locations=self._mine_positions, + revealed_cells=self._revealed_cells, + flags=self._flags_placed, + mine_counts=self._mine_counts, + game_status=self._game_status, + ) + + def get_legal_actions(self) -> List[MinesweeperAction]: + """ + Get the list of legal actions available in the current state. + + Returns: + List of MinesweeperAction instances representing legal actions + """ + legal_actions = [] + + # If game is over, no legal actions + if self._game_status != GameStatus.ONGOING: + return legal_actions + + for r in range(self.height): + for c in range(self.width): + if (r, c) not in self._revealed_cells and (r, c) not in self._flags_placed: + legal_actions.append(MinesweeperAction(row=r, col=c, action_type="reveal")) + if (r, c) not in self._revealed_cells: + legal_actions.append(MinesweeperAction(row=r, col=c, action_type="flag")) + return legal_actions \ No newline at end of file From 9730c27f46ee453724368935000b8c2ac567ee28 Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Thu, 4 Dec 2025 17:36:26 -0800 Subject: [PATCH 02/11] Add Docker support and example for Minesweeper environment Add containerization and documentation for the Minesweeper environment to enable easy deployment and usage. Docker support: - Multi-stage Dockerfile based on openenv-base image - Build script for convenient local image creation - Health check endpoint configuration - Optimized layer caching for faster builds Documentation: - Comprehensive README with usage examples - Quick start guide with code samples - Environment details (actions, observations, rewards) - Docker build and deployment instructions - Configuration options and default settings Example: - Interactive agent demonstration script - Shows environment lifecycle (reset, step, close) - Demonstrates both reveal and flag actions - Example output with game state visualization --- examples/minesweeper_agent.py | 299 ++++++++++++++++++ src/envs/minesweeper_env/README.md | 143 +++++++++ src/envs/minesweeper_env/server/Dockerfile | 67 ++++ .../minesweeper_env/server/build_docker.sh | 48 +++ 4 files changed, 557 insertions(+) create mode 100644 examples/minesweeper_agent.py create mode 100644 src/envs/minesweeper_env/README.md create mode 100644 src/envs/minesweeper_env/server/Dockerfile create mode 100755 src/envs/minesweeper_env/server/build_docker.sh diff --git a/examples/minesweeper_agent.py b/examples/minesweeper_agent.py new file mode 100644 index 000000000..3a74a31bf --- /dev/null +++ b/examples/minesweeper_agent.py @@ -0,0 +1,299 @@ +"""Minesweeper Agent Loop Example. + +This script demonstrates how to create an agent that interacts with the Minesweeper +environment. The agent uses a simple strategy to play the game. + +Prerequisites: +- Minesweeper Docker container must be running +- Default URL: http://localhost:8000 + +Usage: + python examples/minesweeper_agent.py +""" + +import random +import sys +from pathlib import Path +from typing import List, Set, Tuple + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from envs.minesweeper_env import MinesweeperEnv, MinesweeperAction, GameStatus + +# Configuration +MAX_EPISODES = 5 +MAX_STEPS_PER_EPISODE = 100 +BASE_URL = "http://localhost:8000" + + +class SimpleMinesweeperAgent: + """ + A simple agent that plays Minesweeper using a basic strategy: + 1. Start by revealing a corner cell (safer statistically) + 2. Reveal cells with 0 adjacent mines first + 3. Flag cells that are definitely mines based on revealed numbers + 4. Make random safe moves when no obvious safe cells exist + """ + + def __init__(self): + self.revealed_positions: Set[Tuple[int, int]] = set() + self.flagged_positions: Set[Tuple[int, int]] = set() + self.board_height = 0 + self.board_width = 0 + + def reset(self): + """Reset agent state for a new episode.""" + self.revealed_positions.clear() + self.flagged_positions.clear() + self.board_height = 0 + self.board_width = 0 + + def choose_action(self, observation) -> MinesweeperAction: + """ + Choose the next action based on the current board state. + + Args: + observation: MinesweeperObservation from the environment + + Returns: + MinesweeperAction to take + """ + board = observation.board + self.board_height = len(board) + self.board_width = len(board[0]) if board else 0 + + # Update what we know + self._update_state(board) + + # Strategy 1: If this is the first move, reveal a corner (statistically safer) + if len(self.revealed_positions) == 0: + return MinesweeperAction(row=0, col=0, action_type="reveal") + + # Strategy 2: Look for cells that are definitely safe + safe_cells = self._find_safe_cells(board) + if safe_cells: + row, col = random.choice(list(safe_cells)) + return MinesweeperAction(row=row, col=col, action_type="reveal") + + # Strategy 3: Look for cells that are definitely mines and flag them + mine_cells = self._find_definite_mines(board) + if mine_cells: + row, col = random.choice(list(mine_cells)) + return MinesweeperAction(row=row, col=col, action_type="flag") + + # Strategy 4: Make a random move on unrevealed cells (risky but necessary) + unrevealed = self._get_unrevealed_unflagged_cells(board) + if unrevealed: + row, col = random.choice(list(unrevealed)) + return MinesweeperAction(row=row, col=col, action_type="reveal") + + # Fallback: no-op (shouldn't reach here) + return MinesweeperAction(row=0, col=0, action_type="reveal") + + def _update_state(self, board): + """Update internal state based on current board.""" + for r in range(len(board)): + for c in range(len(board[0])): + cell = board[r][c] + if cell != -1 and cell != 'F': + self.revealed_positions.add((r, c)) + if cell == 'F': + self.flagged_positions.add((r, c)) + + def _get_unrevealed_unflagged_cells(self, board) -> Set[Tuple[int, int]]: + """Get all cells that are unrevealed and not flagged.""" + unrevealed = set() + for r in range(len(board)): + for c in range(len(board[0])): + if board[r][c] == -1: + unrevealed.add((r, c)) + return unrevealed + + def _find_safe_cells(self, board) -> Set[Tuple[int, int]]: + """ + Find cells that are definitely safe based on revealed numbers. + + A cell is safe if it's adjacent to a revealed cell whose mine count + equals the number of flags around it. + """ + safe_cells = set() + + for r in range(len(board)): + for c in range(len(board[0])): + cell = board[r][c] + + # Only check revealed numbered cells + if isinstance(cell, int) and 0 <= cell <= 8: + neighbors = self._get_neighbors(r, c) + unrevealed_neighbors = [ + (nr, nc) for nr, nc in neighbors + if board[nr][nc] == -1 + ] + flagged_neighbors = [ + (nr, nc) for nr, nc in neighbors + if board[nr][nc] == 'F' + ] + + # If all mines are flagged, remaining unrevealed cells are safe + if len(flagged_neighbors) == cell: + safe_cells.update(unrevealed_neighbors) + + return safe_cells + + def _find_definite_mines(self, board) -> Set[Tuple[int, int]]: + """ + Find cells that are definitely mines based on revealed numbers. + + A cell is definitely a mine if it's adjacent to a revealed cell whose + mine count equals the number of unrevealed + flagged neighbors. + """ + mine_cells = set() + + for r in range(len(board)): + for c in range(len(board[0])): + cell = board[r][c] + + # Only check revealed numbered cells with value > 0 + if isinstance(cell, int) and 1 <= cell <= 8: + neighbors = self._get_neighbors(r, c) + unrevealed_neighbors = [ + (nr, nc) for nr, nc in neighbors + if board[nr][nc] == -1 + ] + flagged_neighbors = [ + (nr, nc) for nr, nc in neighbors + if board[nr][nc] == 'F' + ] + + # If unrevealed + flagged equals mine count, + # all unrevealed are mines + if len(unrevealed_neighbors) + len(flagged_neighbors) == cell: + mine_cells.update(unrevealed_neighbors) + + return mine_cells + + def _get_neighbors(self, row: int, col: int) -> List[Tuple[int, int]]: + """Get all valid neighboring cells.""" + neighbors = [] + for dr in [-1, 0, 1]: + for dc in [-1, 0, 1]: + if dr == 0 and dc == 0: + continue + nr, nc = row + dr, col + dc + if 0 <= nr < self.board_height and 0 <= nc < self.board_width: + neighbors.append((nr, nc)) + return neighbors + + +def print_board(board): + """Pretty print the Minesweeper board.""" + print("\n ", end="") + if board: + for c in range(len(board[0])): + print(f"{c:3}", end="") + print() + + for r, row in enumerate(board): + print(f"{r:2} ", end="") + for cell in row: + if cell == -1: + print(" . ", end="") + elif cell == 'F': + print(" F ", end="") + elif cell == '*': + print(" * ", end="") + else: + print(f" {cell} ", end="") + print() + print() + + +def main(): + """Run the agent loop.""" + print("=" * 60) + print("Minesweeper Agent Loop") + print("=" * 60) + + # Connect to the environment + print(f"Connecting to Minesweeper server at {BASE_URL}...") + + # Option 1: Connect to existing server + env = MinesweeperEnv(base_url=BASE_URL) + + # Option 2: Start from Docker image (uncomment to use) + # env = MinesweeperEnv.from_docker_image("minesweeper_env-env:latest") + + agent = SimpleMinesweeperAgent() + + total_wins = 0 + total_losses = 0 + + try: + for episode in range(1, MAX_EPISODES + 1): + print(f"\n{'=' * 60}") + print(f"Episode {episode}/{MAX_EPISODES}") + print(f"{'=' * 60}") + + agent.reset() + result = env.reset() + observation = result.observation + + print(f"Board size: {observation.board_height}x{observation.board_width}") + print(f"Number of mines: {observation.num_mines}") + print_board(observation.board) + + episode_reward = 0.0 + + for step in range(1, MAX_STEPS_PER_EPISODE + 1): + if result.done: + break + + # Agent chooses action + action = agent.choose_action(observation) + + # Execute action + result = env.step(action) + observation = result.observation + reward = result.reward or 0.0 + episode_reward += reward + + print(f"Step {step}: {action.action_type} ({action.row}, {action.col}) -> reward: {reward:+.2f}") + print_board(observation.board) + + # Handle game_status being either an Enum or an int + if isinstance(observation.game_status, GameStatus): + status_name = observation.game_status.name + elif isinstance(observation.game_status, int): + status_name = GameStatus(observation.game_status).name + else: + status_name = str(observation.game_status) + + print(f"Status: {status_name} | Revealed: {observation.cells_revealed} | Flags: {observation.flags_placed}") + + if result.done: + if status_name == "WON": + print(f"\nšŸŽ‰ Episode {episode}: WON! Total reward: {episode_reward:.2f}") + total_wins += 1 + elif status_name == "LOST": + print(f"\nšŸ’„ Episode {episode}: LOST! Total reward: {episode_reward:.2f}") + total_losses += 1 + break + else: + print(f"\nā° Episode {episode}: Reached max steps ({MAX_STEPS_PER_EPISODE})") + + # Summary + print(f"\n{'=' * 60}") + print("Summary") + print(f"{'=' * 60}") + print(f"Episodes played: {MAX_EPISODES}") + print(f"Wins: {total_wins} ({100 * total_wins / MAX_EPISODES:.1f}%)") + print(f"Losses: {total_losses} ({100 * total_losses / MAX_EPISODES:.1f}%)") + + finally: + env.close() + print("\nEnvironment closed.") + + +if __name__ == "__main__": + main() diff --git a/src/envs/minesweeper_env/README.md b/src/envs/minesweeper_env/README.md new file mode 100644 index 000000000..c90f43417 --- /dev/null +++ b/src/envs/minesweeper_env/README.md @@ -0,0 +1,143 @@ +# Minesweeper Environment + +A Minesweeper game environment for reinforcement learning agents. The environment consists of a grid with hidden mines where the agent must reveal all non-mine cells without triggering any mines. + +## Overview + +The agent can perform two types of actions: +- Reveal cells to uncover numbers indicating adjacent mines +- Place or remove flags on suspected mine locations + +The game ends when all non-mine cells are revealed (win) or a mine is revealed (loss). + +## Quick Start + +```python +from envs.minesweeper_env import MinesweeperAction, MinesweeperEnv + +# Create environment from Docker image +minesweeper_env = MinesweeperEnv.from_docker_image("minesweeper-env:latest") + +try: + # Reset the environment + result = minesweeper_env.reset() + print(f"Board size: {result.observation.board_height}x{result.observation.board_width}") + print(f"Number of mines: {result.observation.num_mines}") + + # Reveal a cell + result = minesweeper_env.step(MinesweeperAction(row=2, col=2, action_type="reveal")) + print(f"Cells revealed: {result.observation.cells_revealed}") + print(f"Reward: {result.observation.reward}") + + # Place a flag + result = minesweeper_env.step(MinesweeperAction(row=1, col=1, action_type="flag")) + print(f"Flags placed: {result.observation.flags_placed}") + +finally: + minesweeper_env.close() +``` + +## Building the Docker Image + +Build the Docker image from the project root: + +```bash +docker build -t minesweeper-env:latest -f src/envs/minesweeper_env/server/Dockerfile . +``` + +Or use the build script: + +```bash +cd src/envs/minesweeper_env/server +./build_docker.sh latest +``` + +## Environment Details + +### Action + +**MinesweeperAction**: Specifies the cell and action type +- `row` (int) - Row index (0-indexed) +- `col` (int) - Column index (0-indexed) +- `action_type` (str) - Either "reveal" or "flag" + +### Observation + +**MinesweeperObservation**: Current board state and game information +- `board` (list[list]) - 2D grid showing the current state of each cell: + - `-1`: Unrevealed cell + - `0-8`: Number of adjacent mines (revealed cell) + - `'F'`: Flagged cell + - `'*'`: Mine (only shown when game is lost) +- `num_mines` (int) - Total number of mines on the board +- `flags_placed` (int) - Number of flags currently placed +- `cells_revealed` (int) - Number of cells that have been revealed +- `game_status` (GameStatus) - Current game status (ONGOING, WON, or LOST) +- `done` (bool) - Whether the game has ended +- `reward` (float) - Reward from the last action +- `metadata` (dict) - Additional information + +### Rewards + +- Revealing a safe cell: +1.0 +- Placing a flag on a mine: +0.5 +- Revealing a mine (game over): -10.0 +- Revealing an already revealed cell: -0.05 +- Invalid action: -0.1 + +### Game Status + +- `GameStatus.ONGOING`: Game is still in progress +- `GameStatus.WON`: All non-mine cells have been revealed +- `GameStatus.LOST`: A mine was revealed + +## Configuration + +The default configuration is: +- Board height: 5 +- Board width: 5 +- Number of mines: 5 + +These can be configured when initializing the environment server. + +## Connecting to an Existing Server + +If you have a server already running: + +```python +from envs.minesweeper_env import MinesweeperEnv + +# Connect to existing server +minesweeper_env = MinesweeperEnv(base_url="http://localhost:8000") + +# Use as normal +result = minesweeper_env.reset() +``` + +Note: When connecting to an existing server, `close()` will not stop the server. + +## Running Tests + +Run the test suite: + +```bash +python tests/envs/test_minesweeper_env.py +``` + +## Project Structure + +``` +minesweeper_env/ +ā”œā”€ā”€ __init__.py # Module exports +ā”œā”€ā”€ README.md # This file +ā”œā”€ā”€ client.py # MinesweeperEnv client implementation +ā”œā”€ā”€ models.py # Action, Observation, and State models +ā”œā”€ā”€ openenv.yaml # Environment configuration +ā”œā”€ā”€ pyproject.toml # Package dependencies +└── server/ + ā”œā”€ā”€ __init__.py # Server module exports + ā”œā”€ā”€ minesweeper_environment.py # Core game logic + ā”œā”€ā”€ app.py # FastAPI application + ā”œā”€ā”€ Dockerfile # Container image definition + └── build_docker.sh # Build script +``` diff --git a/src/envs/minesweeper_env/server/Dockerfile b/src/envs/minesweeper_env/server/Dockerfile new file mode 100644 index 000000000..3445e28c9 --- /dev/null +++ b/src/envs/minesweeper_env/server/Dockerfile @@ -0,0 +1,67 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest +FROM ${BASE_IMAGE} AS builder + +WORKDIR /app + +# Ensure git is available +RUN apt-get update && \ + apt-get install -y --no-install-recommends git && \ + rm -rf /var/lib/apt/lists/* + +# Build argument to control whether we are building standalone or in-repo +ARG BUILD_MODE=in-repo +ARG ENV_NAME=minesweeper + +# Copy openenv-core source (assumes build context is OpenEnv root) +COPY src/pyproject.toml /app/openenv-core/pyproject.toml +COPY src/core /app/openenv-core/core +COPY src/openenv_cli /app/openenv-core/openenv_cli +COPY src/__init__.py /app/openenv-core/__init__.py + +# Copy the environment source code +COPY src/envs/minesweeper_env /app/env + +# For in-repo builds, openenv-core is already in the pyproject.toml dependencies +# For standalone builds, openenv-core will be installed from PyPI via pyproject.toml +WORKDIR /app/env + +# Ensure uv is available +RUN if ! command -v uv > /dev/null 2>&1; then \ + curl -LsSf https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx; \ + fi + +# Install dependencies and create virtual environment +RUN uv venv /app/env/.venv && \ + uv pip install --python /app/env/.venv/bin/python --no-cache /app/openenv-core && \ + uv pip install --python /app/env/.venv/bin/python --no-cache . + +# Final runtime stage +FROM ${BASE_IMAGE} AS runtime + +WORKDIR /app + +# Copy the built environment from the builder stage +COPY --from=builder /app/env/.venv /app/.venv + +# Copy the environment source code +COPY --from=builder /app/env /app/env + +# Set PATH to use the virtual environment +ENV PATH="/app/.venv/bin:$PATH" + +# Set PYTHONPATH +ENV PYTHONPATH="/app/env:$PYTHONPATH" + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD curl -f http://localhost:8000/health || exit 1 + +# Run the FastAPI server +CMD ["/app/.venv/bin/uvicorn", "minesweeper.server.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/envs/minesweeper_env/server/build_docker.sh b/src/envs/minesweeper_env/server/build_docker.sh new file mode 100755 index 000000000..4397c7f1b --- /dev/null +++ b/src/envs/minesweeper_env/server/build_docker.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +# Script to build the Minesweeper environment Docker image +# Usage: ./build_docker.sh [tag] + +set -e + +TAG="${1:-latest}" +IMAGE_NAME="minesweeper-env:${TAG}" + +echo "🐳 Building Minesweeper Environment Docker Image" +echo "================================================" +echo "Image: $IMAGE_NAME" +echo "" + +# Get script directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Navigate to OpenEnv root (4 levels up from server/) +OPENENV_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +echo "šŸ“ OpenEnv root: $OPENENV_ROOT" +echo "" + +# Build Minesweeper environment image +echo "ā³ Building..." +docker build \ + -f "$SCRIPT_DIR/Dockerfile" \ + -t "$IMAGE_NAME" \ + "$OPENENV_ROOT" + +if [ $? -eq 0 ]; then + echo "" + echo "āœ… Build successful!" + echo "" + echo "šŸš€ Run with:" + echo " docker run -p 8000:8000 $IMAGE_NAME" + echo "" +else + echo "" + echo "āŒ Build failed!" + exit 1 +fi From e147894443ccf65a77bfb9e8d8327e124f682ca4 Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Thu, 4 Dec 2025 17:36:47 -0800 Subject: [PATCH 03/11] Add tests and CI/CD integration for Minesweeper environment Add comprehensive test suite and GitHub Actions workflow integration for automated building and deployment of the Minesweeper environment. Test suite (13 test cases): - Server setup and health checks - Initial state validation (board size, unrevealed cells) - Action tests (reveal, flag, toggle flag) - Edge cases (invalid positions, already revealed cells) - Game state management and status tracking - Board cell value validation - Reset functionality and state cleanup - Multi-step action sequences - Proper handling of HTTP serialization for enums CI/CD integration: - Add minesweeper-env to GitHub Actions build matrix - Automated Docker image building and publishing - Multi-platform support (linux/amd64, linux/arm64) - Image pushed to GitHub Container Registry --- .github/workflows/docker-build.yml | 2 + tests/envs/test_minesweeper_env.py | 292 +++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 tests/envs/test_minesweeper_env.py diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 6c79bd147..3974e4b02 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -81,6 +81,8 @@ jobs: dockerfile: envs/git_env/server/Dockerfile - name: connect4_env dockerfile: envs/connect4_env/server/Dockerfile + - name: minesweeper-env + dockerfile: src/envs/minesweeper_env/server/Dockerfile - name: tbench2-env dockerfile: envs/tbench2_env/server/Dockerfile - name: textarena-env diff --git a/tests/envs/test_minesweeper_env.py b/tests/envs/test_minesweeper_env.py new file mode 100644 index 000000000..afd449065 --- /dev/null +++ b/tests/envs/test_minesweeper_env.py @@ -0,0 +1,292 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Test suite for Minesweeper Environment.""" + +import sys +import os +from pathlib import Path + +# Add src to PYTHONPATH for proper imports +ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SRC_PATH = os.path.join(ROOT_DIR, "src") +sys.path.insert(0, SRC_PATH) +os.environ["PYTHONPATH"] = SRC_PATH + +from envs.minesweeper_env import ( + MinesweeperAction, + MinesweeperObservation, + GameStatus, + MinesweeperEnv, +) +import subprocess +import unittest +import time +import requests +import signal + + +class TestMinesweeperEnv(unittest.TestCase): + """Test cases for the Minesweeper environment.""" + + def __init__(self, methodName="runTest"): + self.client = None + self.server_process = None + super().__init__(methodName) + + def test_setup_server(self): + """Set up the Minesweeper server for testing.""" + self.server_process = subprocess.Popen( + ["python", "-m", "envs.minesweeper_env.server.app"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + # Give it a few seconds to start + time.sleep(3) + + def check_server_running(self): + """Check if the server is running and healthy.""" + try: + response = requests.get("http://127.0.0.1:8000/health") + self.assertEqual(response.status_code, 200) + except requests.ConnectionError: + self.fail("Server did not start or is unreachable") + + def test_minesweeper_env_client(self): + """Test Minesweeper environment client initialization.""" + self.test_setup_server() + self.check_server_running() + + self.client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + assert isinstance(self.client, MinesweeperEnv) + + def test_minesweeper_initial_state(self): + """Test the initial state after reset.""" + self.test_minesweeper_env_client() + + result = self.client.reset() + observation = result.observation + + # Check observation type and attributes + assert isinstance(observation, MinesweeperObservation) + assert isinstance(observation.board, list) + assert isinstance(observation.done, bool) + assert isinstance(observation.reward, float) + assert isinstance(observation.num_mines, int) + assert isinstance(observation.flags_placed, int) + assert isinstance(observation.cells_revealed, int) + # game_status may be int or GameStatus enum due to HTTP serialization + assert isinstance(observation.game_status, (GameStatus, int)) + + # Check initial state values + assert observation.done == False + assert observation.reward == 0.0 + assert observation.flags_placed == 0 + assert observation.cells_revealed == 0 + # Compare with enum value (handles both int and enum) + assert observation.game_status == GameStatus.ONGOING.value or observation.game_status == GameStatus.ONGOING + + # Check board structure (default 5x5) + assert len(observation.board) == 5 # 5 rows + assert all(len(row) == 5 for row in observation.board) # 5 columns + + # Check all cells are initially unrevealed + assert all( + cell == -1 for row in observation.board for cell in row + ), "All cells should be unrevealed (-1) at start" + + def test_reveal_action(self): + """Test revealing a cell.""" + self.test_minesweeper_env_client() + self.client.reset() + + # Try revealing a cell + action = MinesweeperAction(row=0, col=0, action_type="reveal") + result = self.client.step(action) + observation = result.observation + + assert isinstance(observation, MinesweeperObservation) + assert observation.cells_revealed > 0, "At least one cell should be revealed" + + def test_flag_action(self): + """Test placing a flag.""" + self.test_minesweeper_env_client() + self.client.reset() + + # Place a flag + action = MinesweeperAction(row=1, col=1, action_type="flag") + result = self.client.step(action) + observation = result.observation + + assert isinstance(observation, MinesweeperObservation) + assert observation.flags_placed == 1, "One flag should be placed" + assert observation.board[1][1] == "F", "Cell should show flag marker" + + def test_toggle_flag(self): + """Test toggling a flag on and off.""" + self.test_minesweeper_env_client() + self.client.reset() + + # Place a flag + action = MinesweeperAction(row=2, col=2, action_type="flag") + result = self.client.step(action) + observation = result.observation + assert observation.flags_placed == 1 + + # Remove the flag + action = MinesweeperAction(row=2, col=2, action_type="flag") + result = self.client.step(action) + observation = result.observation + assert observation.flags_placed == 0, "Flag should be removed" + + def test_invalid_position(self): + """Test action with invalid position.""" + self.test_minesweeper_env_client() + self.client.reset() + + # Try invalid row + action = MinesweeperAction(row=10, col=0, action_type="reveal") + result = self.client.step(action) + observation = result.observation + + assert observation.reward < 0, "Should receive negative reward for invalid action" + + # Try invalid column + action = MinesweeperAction(row=0, col=10, action_type="reveal") + result = self.client.step(action) + observation = result.observation + + assert observation.reward < 0, "Should receive negative reward for invalid action" + + def test_reveal_already_revealed(self): + """Test revealing an already revealed cell.""" + self.test_minesweeper_env_client() + self.client.reset() + + # Reveal a cell - try multiple cells to ensure one gets revealed + # (some cells might cascade reveal if they have 0 adjacent mines) + action = MinesweeperAction(row=2, col=2, action_type="reveal") + result = self.client.step(action) + first_reward = result.observation.reward + + # Make sure the cell was actually revealed (could be revealed by cascade) + # If it wasn't revealed successfully, try another cell + if result.observation.board[2][2] == -1: + action = MinesweeperAction(row=1, col=1, action_type="reveal") + result = self.client.step(action) + test_row, test_col = 1, 1 + else: + test_row, test_col = 2, 2 + + # Try revealing the same cell again + action = MinesweeperAction(row=test_row, col=test_col, action_type="reveal") + result = self.client.step(action) + second_reward = result.observation.reward + + assert second_reward < 0, f"Should receive penalty for revealing already revealed cell, got {second_reward}" + + def test_game_status_ongoing(self): + """Test that game status remains ONGOING during normal play.""" + self.test_minesweeper_env_client() + self.client.reset() + + # Make a few safe moves + action = MinesweeperAction(row=0, col=0, action_type="reveal") + result = self.client.step(action) + + # Game should still be ongoing if we didn't hit a mine or win + # Handle both int and enum types for game_status + if (result.observation.game_status == GameStatus.ONGOING.value or + result.observation.game_status == GameStatus.ONGOING): + assert result.observation.done == False + + def test_board_cell_values(self): + """Test that board cells contain valid values.""" + self.test_minesweeper_env_client() + self.client.reset() + + # Reveal a cell + action = MinesweeperAction(row=2, col=2, action_type="reveal") + result = self.client.step(action) + observation = result.observation + + # Check that revealed cells have valid values (0-8 or '*') + for row in observation.board: + for cell in row: + assert ( + cell == -1 # Unrevealed + or cell == "F" # Flagged + or cell == "*" # Mine (if revealed) + or (isinstance(cell, int) and 0 <= cell <= 8) # Number of adjacent mines + ), f"Invalid cell value: {cell}" + + def test_metadata_in_observation(self): + """Test that observations contain metadata.""" + self.test_minesweeper_env_client() + result = self.client.reset() + observation = result.observation + + assert hasattr(observation, "metadata"), "Observation should have metadata" + assert isinstance(observation.metadata, dict), "Metadata should be a dictionary" + + def test_multiple_steps(self): + """Test taking multiple steps in the environment.""" + self.test_minesweeper_env_client() + self.client.reset() + + # Take several actions + actions = [ + MinesweeperAction(row=0, col=0, action_type="reveal"), + MinesweeperAction(row=0, col=1, action_type="flag"), + MinesweeperAction(row=1, col=0, action_type="reveal"), + ] + + for action in actions: + result = self.client.step(action) + assert isinstance(result.observation, MinesweeperObservation) + + def test_reset_clears_state(self): + """Test that reset properly clears the game state.""" + self.test_minesweeper_env_client() + + # First game + self.client.reset() + action = MinesweeperAction(row=0, col=0, action_type="flag") + self.client.step(action) + + # Reset and check state is cleared + result = self.client.reset() + observation = result.observation + + assert observation.flags_placed == 0, "Flags should be cleared after reset" + assert observation.cells_revealed == 0, "Revealed cells should be cleared after reset" + # Compare with enum value (handles both int and enum) + assert (observation.game_status == GameStatus.ONGOING.value or + observation.game_status == GameStatus.ONGOING), "Game should be ongoing after reset" + + def tearDown(self): + """Clean up after tests.""" + if self.server_process: + # Try terminating the process gracefully + self.server_process.terminate() + try: + self.server_process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.kill(self.server_process.pid, signal.SIGKILL) + + # Close the pipes to avoid ResourceWarnings + for stream in [ + self.server_process.stdin, + self.server_process.stdout, + self.server_process.stderr, + ]: + if stream and not stream.closed: + stream.close() + + +if __name__ == "__main__": + unittest.main() From 1469cc8e91cbd1245c9add49f2f9c7a71eea2d1e Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Fri, 16 Jan 2026 09:14:22 -0800 Subject: [PATCH 04/11] Update minesweeper environment to align with OpenEnv standards - Changed client from HTTPEnvClient to EnvClient (WebSocket) - Updated all imports from openenv_core to openenv.core pattern - Converted models from @dataclass to pure Pydantic with Field validation - Fixed copy-paste documentation from echo_env template - Simplified server/app.py to match new factory pattern - Added missing newlines at end of files (POSIX compliance) - Fixed Docker workflow merge conflict (removed duplicate entries) Co-Authored-By: Claude Sonnet 4.5 --- src/envs/minesweeper_env/client.py | 61 +++++---- src/envs/minesweeper_env/models.py | 118 ++++++++++-------- src/envs/minesweeper_env/openenv.yaml | 2 +- src/envs/minesweeper_env/pyproject.toml | 2 +- src/envs/minesweeper_env/server/Dockerfile | 2 +- src/envs/minesweeper_env/server/app.py | 82 +++--------- .../server/minesweeper_environment.py | 17 ++- 7 files changed, 134 insertions(+), 150 deletions(-) diff --git a/src/envs/minesweeper_env/client.py b/src/envs/minesweeper_env/client.py index 31e101e04..4b08bd086 100644 --- a/src/envs/minesweeper_env/client.py +++ b/src/envs/minesweeper_env/client.py @@ -5,44 +5,57 @@ # LICENSE file in the root directory of this source tree. """ -Minesweeper Environment HTTP Client. +Minesweeper Environment Client. This module provides the client for connecting to a Minesweeper Environment server -over HTTP. +via WebSocket for persistent sessions. """ from typing import Any, Dict -from openenv_core.client_types import StepResult -from openenv_core.env_server.types import State -from openenv_core.http_env_client import HTTPEnvClient - -from .models import MinesweeperAction, MinesweeperObservation - - -class MinesweeperEnv(HTTPEnvClient[MinesweeperAction, MinesweeperObservation]): +# Support both in-repo and standalone imports +try: + # In-repo imports (when running from OpenEnv repository) + from openenv.core.client_types import StepResult + from openenv.core.env_server.types import State + from openenv.core.env_client import EnvClient + from .models import MinesweeperAction, MinesweeperObservation +except ImportError: + # Standalone imports (when environment is standalone with openenv from pip) + from openenv.core.client_types import StepResult + from openenv.core.env_server.types import State + from openenv.core.env_client import EnvClient + from models import MinesweeperAction, MinesweeperObservation + + +class MinesweeperEnv(EnvClient[MinesweeperAction, MinesweeperObservation, State]): """ - HTTP client for the Minesweeper Environment. + Client for the Minesweeper Environment. - This client connects to a MinesweeperEnvironment HTTP server and provides - methods to interact with it: reset(), step(), and state access. + This client maintains a persistent WebSocket connection to the environment + server, enabling efficient multi-step interactions with lower latency. + Each client instance has its own dedicated environment session on the server. Example: >>> # Connect to a running server - >>> client = MinesweeperEnv(base_url="http://localhost:8000") - >>> result = client.reset() - >>> print(result.observation.echoed_message) - >>> - >>> # Send a message - >>> result = client.step(MinesweeperAction(message="Hello!")) - >>> print(result.observation.echoed_message) - >>> print(result.reward) + >>> with MinesweeperEnv(base_url="http://localhost:8000") as client: + ... result = client.reset() + ... print(result.observation.board) + ... print(result.observation.game_status) + ... + ... # Reveal a cell + ... result = client.step(MinesweeperAction(row=0, col=0, action_type="reveal")) + ... print(result.observation.board) + ... print(result.reward) Example with Docker: >>> # Automatically start container and connect - >>> client = MinesweeperEnv.from_docker_image("minesweeper_env-env:latest") - >>> result = client.reset() - >>> result = client.step(MinesweeperAction(message="Test")) + >>> client = MinesweeperEnv.from_docker_image("minesweeper-env:latest") + >>> try: + ... result = client.reset() + ... result = client.step(MinesweeperAction(row=2, col=3, action_type="reveal")) + ... finally: + ... client.close() """ def _step_payload(self, action: MinesweeperAction) -> Dict: diff --git a/src/envs/minesweeper_env/models.py b/src/envs/minesweeper_env/models.py index 15f65722c..8f8959d1a 100644 --- a/src/envs/minesweeper_env/models.py +++ b/src/envs/minesweeper_env/models.py @@ -5,80 +5,97 @@ # LICENSE file in the root directory of this source tree. """ -Data models for the Minesweeper Env Environment. +Data models for the Minesweeper Environment. -The minesweeper_env environment is a simple test environment that echoes back messages. +The minesweeper_env environment is a Minesweeper game where agents reveal cells and place flags +to identify mines on a grid board. """ -from dataclasses import dataclass from enum import Enum -from typing import List, Any, Dict, Optional, Set, Tuple -from openenv_core.env_server.types import Action, Observation +from typing import List, Any, Set, Tuple +from pydantic import Field, BaseModel + +# Support both in-repo and standalone imports +try: + # In-repo imports (when running from OpenEnv repository) + from openenv.core.env_server.types import Action, Observation +except ImportError: + # Standalone imports (when environment is standalone with openenv from pip) + from openenv.core.env_server.types import Action, Observation + class GameStatus(Enum): - ONGOING = 0 - WON = 1 - LOST = 2 + """Status of the Minesweeper game.""" + ONGOING = "ongoing" + WON = "won" + LOST = "lost" + -@dataclass(kw_only=True) class MinesweeperAction(Action): - """Action for the Minesweeper environment Attributes: - row: Row index of the cell to act on (0-indexed). - col: Column index of the cell to act on (0-indexed). - action_type: Type of action - 'reveal' to uncover a cell, 'flag' to place a flag/remove a flag.""" + """ + Action for the Minesweeper environment. - row: int - col: int - action_type: str # 'reveal' or 'flag' + Attributes: + row: Row index of the cell to act on (0-indexed). + col: Column index of the cell to act on (0-indexed). + action_type: Type of action - 'reveal' to uncover a cell, 'flag' to place/remove a flag. + """ + row: int = Field(..., ge=0, description="Row index of the cell") + col: int = Field(..., ge=0, description="Column index of the cell") + action_type: str = Field(..., pattern="^(reveal|flag)$", description="Type of action: 'reveal' or 'flag'") -@dataclass(kw_only=True) class MinesweeperObservation(Observation): - """Observation from the Minesweeper environment + """ + Observation from the Minesweeper environment. + This represents what the agent can see - a partial view of the board with hidden mine locations (unless revealed). + Attributes: - board: 2D list representing the current state of the board. Each cell can be: - - -1: unrevealed - - 0-8: number of adjacent mines (if revealed) - - 'F: flagged cell - - * : mine (only revealed if game is lost) - num_mines: Total number of mines on the board. - flags_placed: Number of flags currently placed by the agent. - cells_revealed: Number of cells that have been revealed so far. - game_status: Current status of the game - ongoing, won, or lost. + board: 2D list representing the current state of the board. Each cell can be: + - -1: unrevealed + - 0-8: number of adjacent mines (if revealed) + - 'F': flagged cell + - '*': mine (only revealed if game is lost) + num_mines: Total number of mines on the board. + flags_placed: Number of flags currently placed by the agent. + cells_revealed: Number of cells that have been revealed so far. + game_status: Current status of the game - ongoing, won, or lost. """ - - board: List[List[Any]] - num_mines: int - flags_placed: int - cells_revealed: int - game_status: GameStatus + board: List[List[Any]] = Field(default_factory=list, description="2D board state") + num_mines: int = Field(..., ge=0, description="Total number of mines") + flags_placed: int = Field(..., ge=0, description="Number of flags placed") + cells_revealed: int = Field(..., ge=0, description="Number of cells revealed") + game_status: GameStatus = Field(..., description="Current game status") @property def board_height(self) -> int: """Height of the board (number of rows).""" return len(self.board) + @property def board_width(self) -> int: """Width of the board (number of columns).""" return len(self.board[0]) if self.board else 0 - -@dataclass(kw_only=True) -class MinesweeperState: - """State of the Minesweeper environment. + + +class MinesweeperState(BaseModel): + """ + Internal state of the Minesweeper environment. + This represents the full internal state of the environment, including hidden information. + Attributes: - episode_id: Unique identifier for the current episode. - step_count: Number of steps taken in the current episode. - board_height: Height of the board (number of rows). - board_width: Width of the board (number of columns). - mine_locations: Set of (row, col) tuples indicating where mines are located. - revealed_cells: Set of (row, col) tuples indicating which cells have been revealed. - flags: Set of (row, col) tuples indicating where flags have been placed. - mine_counts: 2D list with counts of adjacent mines for each cell. - game_status: Current status of the game - ongoing, won, or lost. + episode_id: Unique identifier for the current episode. + step_count: Number of steps taken in the current episode. + board_height: Height of the board (number of rows). + board_width: Width of the board (number of columns). + mine_locations: Set of (row, col) tuples indicating where mines are located. + revealed_cells: Set of (row, col) tuples indicating which cells have been revealed. + flags: Set of (row, col) tuples indicating where flags have been placed. + mine_counts: 2D list with counts of adjacent mines for each cell. + game_status: Current status of the game - ongoing, won, or lost. """ - episode_id: str step_count: int board_height: int @@ -90,8 +107,10 @@ class MinesweeperState: game_status: GameStatus def to_observation(self) -> MinesweeperObservation: - """Convert the full state to a partial observation for the agent. - Returns: + """ + Convert the full state to a partial observation for the agent. + + Returns: MinesweeperObservation representing the agent's view of the board. """ board = [] @@ -123,4 +142,3 @@ def to_observation(self) -> MinesweeperObservation: "step_count": self.step_count, }, ) - diff --git a/src/envs/minesweeper_env/openenv.yaml b/src/envs/minesweeper_env/openenv.yaml index e8ea68e01..1ca9ce903 100644 --- a/src/envs/minesweeper_env/openenv.yaml +++ b/src/envs/minesweeper_env/openenv.yaml @@ -3,4 +3,4 @@ name: minesweeper type: space runtime: fastapi app: server.app:app -port: 8000 \ No newline at end of file +port: 8000 diff --git a/src/envs/minesweeper_env/pyproject.toml b/src/envs/minesweeper_env/pyproject.toml index 8a6c671c5..21135d76b 100644 --- a/src/envs/minesweeper_env/pyproject.toml +++ b/src/envs/minesweeper_env/pyproject.toml @@ -29,4 +29,4 @@ server = "minesweeper.server.app:main" [tool.setuptools] include-package-data = true packages = ["minesweeper", "minesweeper.server"] -package-dir = { "minesweeper" = ".", "minesweeper.server" = "server" } \ No newline at end of file +package-dir = { "minesweeper" = ".", "minesweeper.server" = "server" } diff --git a/src/envs/minesweeper_env/server/Dockerfile b/src/envs/minesweeper_env/server/Dockerfile index 3445e28c9..02f1075ba 100644 --- a/src/envs/minesweeper_env/server/Dockerfile +++ b/src/envs/minesweeper_env/server/Dockerfile @@ -64,4 +64,4 @@ ENV PYTHONPATH="/app/env:$PYTHONPATH" HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD curl -f http://localhost:8000/health || exit 1 # Run the FastAPI server -CMD ["/app/.venv/bin/uvicorn", "minesweeper.server.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["/app/.venv/bin/uvicorn", "minesweeper.server.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/src/envs/minesweeper_env/server/app.py b/src/envs/minesweeper_env/server/app.py index 7afb6592f..96d1ec9da 100644 --- a/src/envs/minesweeper_env/server/app.py +++ b/src/envs/minesweeper_env/server/app.py @@ -4,82 +4,28 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -""" -FastAPI application for the Minesweeper Env Environment. - -This module creates an HTTP server that exposes the MinesweeperEnvironment -over HTTP endpoints, making it compatible with HTTPEnvClient. - -Usage: - # Development (with auto-reload): - uvicorn server.app:app --reload --host 0.0.0.0 --port 8000 - - # Production: - uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4 - - # Or run directly: - python -m server.app -""" +"""FastAPI application for the Minesweeper Environment.""" +# Support both in-repo and standalone imports try: - from openenv_core.env_server.http_server import create_app -except Exception as e: # pragma: no cover - raise ImportError( - "openenv_core is required for the web interface. Install dependencies with '\n" - " uv sync\n'" - ) from e + # In-repo imports (when running from OpenEnv repository) + from openenv.core.env_server import create_app +except ImportError: + # Standalone imports (when environment is standalone with openenv from pip) + from openenv.core.env_server import create_app -from .minesweeper_environment import MinesweeperEnvironment from ..models import MinesweeperAction, MinesweeperObservation +from .minesweeper_environment import MinesweeperEnvironment -# Create the environment instance -env = MinesweeperEnvironment() - -# Create the app with web interface and README integration +# Create the FastAPI app +# Pass the class (factory) instead of an instance for WebSocket session support app = create_app( - env, + MinesweeperEnvironment, MinesweeperAction, MinesweeperObservation, - env_name="minesweeper", + env_name="minesweeper_env" ) - -def main(host: str = "0.0.0.0", port: int = 8000): - """ - Entry point for direct execution via uv run or python -m. - - This function enables running the server without Docker: - uv run --project . server - uv run --project . server --port 8001 - python -m minesweeper_env.server.app - - Args: - host: Host address to bind to (default: "0.0.0.0") - port: Port number to listen on (default: 8000) - - For production deployments, consider using uvicorn directly with - multiple workers: - uvicorn minesweeper_env.server.app:app --workers 4 - """ - import uvicorn - - uvicorn.run(app, host=host, port=port) - - if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser() - parser.add_argument( - "--port", - type=int, - default=8000, - help="Port number to listen on (default: 8000)" - ) - parser.add_argument( - "--host", - type=str, - default="0.0.0.0", - help="Host address to bind to (default: 0.0.0.0)" - ) - args = parser.parse_args() - main(port=args.port, host=args.host) + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/envs/minesweeper_env/server/minesweeper_environment.py b/src/envs/minesweeper_env/server/minesweeper_environment.py index f902842ee..a39a3a0a4 100644 --- a/src/envs/minesweeper_env/server/minesweeper_environment.py +++ b/src/envs/minesweeper_env/server/minesweeper_environment.py @@ -7,8 +7,8 @@ """ Minesweeper Environment Implementation. -A simple test environment that echoes back messages sent to it. -Perfect for testing HTTP server infrastructure. +A Minesweeper game environment where agents must reveal cells and place flags +to identify mines on a grid board without triggering any mines. """ import random from typing import Any, Dict, List, Optional, Set, Tuple @@ -21,8 +21,15 @@ MinesweeperState, ) -from openenv_core.env_server.interfaces import Environment -from openenv_core.env_server.types import State +# Support both in-repo and standalone imports +try: + # In-repo imports (when running from OpenEnv repository) + from openenv.core.env_server.interfaces import Environment + from openenv.core.env_server.types import State +except ImportError: + # Standalone imports (when environment is standalone with openenv from pip) + from openenv.core.env_server.interfaces import Environment + from openenv.core.env_server.types import State class MinesweeperEnvironment(Environment): @@ -319,4 +326,4 @@ def get_legal_actions(self) -> List[MinesweeperAction]: legal_actions.append(MinesweeperAction(row=r, col=c, action_type="reveal")) if (r, c) not in self._revealed_cells: legal_actions.append(MinesweeperAction(row=r, col=c, action_type="flag")) - return legal_actions \ No newline at end of file + return legal_actions From 98bf1d47bf8bea97eeb8871b211c11c375f52214 Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Wed, 21 Jan 2026 08:22:14 -0800 Subject: [PATCH 05/11] Refactor Minesweeper environment tests and Dockerfile CMD for improved clarity and functionality --- src/envs/minesweeper_env/server/Dockerfile | 2 +- .../server/minesweeper_environment.py | 6 +- tests/envs/test_minesweeper_env.py | 142 ++++++++---------- 3 files changed, 68 insertions(+), 82 deletions(-) diff --git a/src/envs/minesweeper_env/server/Dockerfile b/src/envs/minesweeper_env/server/Dockerfile index 02f1075ba..dc1167387 100644 --- a/src/envs/minesweeper_env/server/Dockerfile +++ b/src/envs/minesweeper_env/server/Dockerfile @@ -64,4 +64,4 @@ ENV PYTHONPATH="/app/env:$PYTHONPATH" HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD curl -f http://localhost:8000/health || exit 1 # Run the FastAPI server -CMD ["/app/.venv/bin/uvicorn", "minesweeper.server.app:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["/app/.venv/bin/uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/src/envs/minesweeper_env/server/minesweeper_environment.py b/src/envs/minesweeper_env/server/minesweeper_environment.py index a39a3a0a4..6cbab3546 100644 --- a/src/envs/minesweeper_env/server/minesweeper_environment.py +++ b/src/envs/minesweeper_env/server/minesweeper_environment.py @@ -161,7 +161,7 @@ def _compute_mine_counts(self) -> None: self._mine_counts = [[0 for _ in range(self.width)] for _ in range(self.height)] for row in range(self.height): for col in range(self.width): - if (row,col) not in self._mine_positions: + if (row, col) not in self._mine_positions: count = self._count_adjacent_mines(row, col) self._mine_counts[row][col] = count @@ -182,8 +182,6 @@ def _reveal_cell(self, row: int, col: int) -> float: if (row, col) in self._revealed_cells or (row, col) in self._flags_placed: return -0.05 # Penalty for revealing already revealed or flagged cell - self._revealed_cells.add((row, col)) - if (row, col) in self._mine_positions: self._game_status = GameStatus.LOST self._revealed_cells.add((row, col)) @@ -296,7 +294,7 @@ def get_full_state(self) -> MinesweeperState: MinesweeperState representing the full internal state """ return MinesweeperState( - episode_id=self._state.episode_id, + episode_id=self._state.episode_id or "", step_count=self._state.step_count, board_height=self.height, board_width=self.width, diff --git a/tests/envs/test_minesweeper_env.py b/tests/envs/test_minesweeper_env.py index afd449065..38feda230 100644 --- a/tests/envs/test_minesweeper_env.py +++ b/tests/envs/test_minesweeper_env.py @@ -32,43 +32,50 @@ class TestMinesweeperEnv(unittest.TestCase): """Test cases for the Minesweeper environment.""" - def __init__(self, methodName="runTest"): - self.client = None - self.server_process = None - super().__init__(methodName) - - def test_setup_server(self): - """Set up the Minesweeper server for testing.""" - self.server_process = subprocess.Popen( + server_process = None + + @classmethod + def setUpClass(cls): + """Start the server once for all tests.""" + cls.server_process = subprocess.Popen( ["python", "-m", "envs.minesweeper_env.server.app"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) - # Give it a few seconds to start - time.sleep(3) + time.sleep(3) # Give server time to start - def check_server_running(self): - """Check if the server is running and healthy.""" + # Verify server is running try: response = requests.get("http://127.0.0.1:8000/health") - self.assertEqual(response.status_code, 200) + if response.status_code != 200: + raise RuntimeError("Server health check failed") except requests.ConnectionError: - self.fail("Server did not start or is unreachable") + raise RuntimeError("Server did not start or is unreachable") + + @classmethod + def tearDownClass(cls): + """Clean up server after all tests.""" + if cls.server_process: + cls.server_process.terminate() + try: + cls.server_process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.kill(cls.server_process.pid, signal.SIGKILL) + + for stream in [cls.server_process.stdin, cls.server_process.stdout, cls.server_process.stderr]: + if stream and not stream.closed: + stream.close() def test_minesweeper_env_client(self): """Test Minesweeper environment client initialization.""" - self.test_setup_server() - self.check_server_running() - - self.client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - assert isinstance(self.client, MinesweeperEnv) + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + assert isinstance(client, MinesweeperEnv) def test_minesweeper_initial_state(self): """Test the initial state after reset.""" - self.test_minesweeper_env_client() - - result = self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + result = client.reset() observation = result.observation # Check observation type and attributes @@ -83,7 +90,7 @@ def test_minesweeper_initial_state(self): assert isinstance(observation.game_status, (GameStatus, int)) # Check initial state values - assert observation.done == False + assert observation.done is False assert observation.reward == 0.0 assert observation.flags_placed == 0 assert observation.cells_revealed == 0 @@ -101,12 +108,12 @@ def test_minesweeper_initial_state(self): def test_reveal_action(self): """Test revealing a cell.""" - self.test_minesweeper_env_client() - self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + client.reset() # Try revealing a cell action = MinesweeperAction(row=0, col=0, action_type="reveal") - result = self.client.step(action) + result = client.step(action) observation = result.observation assert isinstance(observation, MinesweeperObservation) @@ -114,12 +121,12 @@ def test_reveal_action(self): def test_flag_action(self): """Test placing a flag.""" - self.test_minesweeper_env_client() - self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + client.reset() # Place a flag action = MinesweeperAction(row=1, col=1, action_type="flag") - result = self.client.step(action) + result = client.step(action) observation = result.observation assert isinstance(observation, MinesweeperObservation) @@ -128,90 +135,90 @@ def test_flag_action(self): def test_toggle_flag(self): """Test toggling a flag on and off.""" - self.test_minesweeper_env_client() - self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + client.reset() # Place a flag action = MinesweeperAction(row=2, col=2, action_type="flag") - result = self.client.step(action) + result = client.step(action) observation = result.observation assert observation.flags_placed == 1 # Remove the flag action = MinesweeperAction(row=2, col=2, action_type="flag") - result = self.client.step(action) + result = client.step(action) observation = result.observation assert observation.flags_placed == 0, "Flag should be removed" def test_invalid_position(self): """Test action with invalid position.""" - self.test_minesweeper_env_client() - self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + client.reset() # Try invalid row action = MinesweeperAction(row=10, col=0, action_type="reveal") - result = self.client.step(action) + result = client.step(action) observation = result.observation assert observation.reward < 0, "Should receive negative reward for invalid action" # Try invalid column action = MinesweeperAction(row=0, col=10, action_type="reveal") - result = self.client.step(action) + result = client.step(action) observation = result.observation assert observation.reward < 0, "Should receive negative reward for invalid action" def test_reveal_already_revealed(self): """Test revealing an already revealed cell.""" - self.test_minesweeper_env_client() - self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + client.reset() # Reveal a cell - try multiple cells to ensure one gets revealed # (some cells might cascade reveal if they have 0 adjacent mines) action = MinesweeperAction(row=2, col=2, action_type="reveal") - result = self.client.step(action) + result = client.step(action) first_reward = result.observation.reward # Make sure the cell was actually revealed (could be revealed by cascade) # If it wasn't revealed successfully, try another cell if result.observation.board[2][2] == -1: action = MinesweeperAction(row=1, col=1, action_type="reveal") - result = self.client.step(action) + result = client.step(action) test_row, test_col = 1, 1 else: test_row, test_col = 2, 2 # Try revealing the same cell again action = MinesweeperAction(row=test_row, col=test_col, action_type="reveal") - result = self.client.step(action) + result = client.step(action) second_reward = result.observation.reward assert second_reward < 0, f"Should receive penalty for revealing already revealed cell, got {second_reward}" def test_game_status_ongoing(self): """Test that game status remains ONGOING during normal play.""" - self.test_minesweeper_env_client() - self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + client.reset() # Make a few safe moves action = MinesweeperAction(row=0, col=0, action_type="reveal") - result = self.client.step(action) + result = client.step(action) # Game should still be ongoing if we didn't hit a mine or win # Handle both int and enum types for game_status if (result.observation.game_status == GameStatus.ONGOING.value or result.observation.game_status == GameStatus.ONGOING): - assert result.observation.done == False + assert result.observation.done is False def test_board_cell_values(self): """Test that board cells contain valid values.""" - self.test_minesweeper_env_client() - self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + client.reset() # Reveal a cell action = MinesweeperAction(row=2, col=2, action_type="reveal") - result = self.client.step(action) + result = client.step(action) observation = result.observation # Check that revealed cells have valid values (0-8 or '*') @@ -226,8 +233,8 @@ def test_board_cell_values(self): def test_metadata_in_observation(self): """Test that observations contain metadata.""" - self.test_minesweeper_env_client() - result = self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + result = client.reset() observation = result.observation assert hasattr(observation, "metadata"), "Observation should have metadata" @@ -235,8 +242,8 @@ def test_metadata_in_observation(self): def test_multiple_steps(self): """Test taking multiple steps in the environment.""" - self.test_minesweeper_env_client() - self.client.reset() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + client.reset() # Take several actions actions = [ @@ -246,20 +253,20 @@ def test_multiple_steps(self): ] for action in actions: - result = self.client.step(action) + result = client.step(action) assert isinstance(result.observation, MinesweeperObservation) def test_reset_clears_state(self): """Test that reset properly clears the game state.""" - self.test_minesweeper_env_client() + client = MinesweeperEnv(base_url="http://127.0.0.1:8000") # First game - self.client.reset() + client.reset() action = MinesweeperAction(row=0, col=0, action_type="flag") - self.client.step(action) + client.step(action) # Reset and check state is cleared - result = self.client.reset() + result = client.reset() observation = result.observation assert observation.flags_placed == 0, "Flags should be cleared after reset" @@ -268,25 +275,6 @@ def test_reset_clears_state(self): assert (observation.game_status == GameStatus.ONGOING.value or observation.game_status == GameStatus.ONGOING), "Game should be ongoing after reset" - def tearDown(self): - """Clean up after tests.""" - if self.server_process: - # Try terminating the process gracefully - self.server_process.terminate() - try: - self.server_process.wait(timeout=5) - except subprocess.TimeoutExpired: - os.kill(self.server_process.pid, signal.SIGKILL) - - # Close the pipes to avoid ResourceWarnings - for stream in [ - self.server_process.stdin, - self.server_process.stdout, - self.server_process.stderr, - ]: - if stream and not stream.closed: - stream.close() - if __name__ == "__main__": unittest.main() From 0601bab5cc2f6445f9ab0ab62a0d82caf39135c7 Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Tue, 10 Feb 2026 22:34:04 -0800 Subject: [PATCH 06/11] Fix minesweeper tests: close WebSocket clients to prevent session capacity errors Tests were creating new MinesweeperEnv clients per test without closing them, exhausting the server's single-session capacity. Added setUp/tearDown for proper client lifecycle and use sys.executable for the server subprocess. Co-Authored-By: Claude Opus 4.6 --- tests/envs/test_minesweeper_env.py | 75 ++++++++++++++---------------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/tests/envs/test_minesweeper_env.py b/tests/envs/test_minesweeper_env.py index 38feda230..e64d6481d 100644 --- a/tests/envs/test_minesweeper_env.py +++ b/tests/envs/test_minesweeper_env.py @@ -38,7 +38,7 @@ class TestMinesweeperEnv(unittest.TestCase): def setUpClass(cls): """Start the server once for all tests.""" cls.server_process = subprocess.Popen( - ["python", "-m", "envs.minesweeper_env.server.app"], + [sys.executable, "-m", "envs.minesweeper_env.server.app"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -67,15 +67,21 @@ def tearDownClass(cls): if stream and not stream.closed: stream.close() + def setUp(self): + """Create a fresh client for each test.""" + self.client = MinesweeperEnv(base_url="http://127.0.0.1:8000") + + def tearDown(self): + """Close the client after each test to free the session slot.""" + self.client.close() + def test_minesweeper_env_client(self): """Test Minesweeper environment client initialization.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - assert isinstance(client, MinesweeperEnv) + assert isinstance(self.client, MinesweeperEnv) def test_minesweeper_initial_state(self): """Test the initial state after reset.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - result = client.reset() + result = self.client.reset() observation = result.observation # Check observation type and attributes @@ -108,12 +114,11 @@ def test_minesweeper_initial_state(self): def test_reveal_action(self): """Test revealing a cell.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - client.reset() + self.client.reset() # Try revealing a cell action = MinesweeperAction(row=0, col=0, action_type="reveal") - result = client.step(action) + result = self.client.step(action) observation = result.observation assert isinstance(observation, MinesweeperObservation) @@ -121,12 +126,11 @@ def test_reveal_action(self): def test_flag_action(self): """Test placing a flag.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - client.reset() + self.client.reset() # Place a flag action = MinesweeperAction(row=1, col=1, action_type="flag") - result = client.step(action) + result = self.client.step(action) observation = result.observation assert isinstance(observation, MinesweeperObservation) @@ -135,75 +139,71 @@ def test_flag_action(self): def test_toggle_flag(self): """Test toggling a flag on and off.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - client.reset() + self.client.reset() # Place a flag action = MinesweeperAction(row=2, col=2, action_type="flag") - result = client.step(action) + result = self.client.step(action) observation = result.observation assert observation.flags_placed == 1 # Remove the flag action = MinesweeperAction(row=2, col=2, action_type="flag") - result = client.step(action) + result = self.client.step(action) observation = result.observation assert observation.flags_placed == 0, "Flag should be removed" def test_invalid_position(self): """Test action with invalid position.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - client.reset() + self.client.reset() # Try invalid row action = MinesweeperAction(row=10, col=0, action_type="reveal") - result = client.step(action) + result = self.client.step(action) observation = result.observation assert observation.reward < 0, "Should receive negative reward for invalid action" # Try invalid column action = MinesweeperAction(row=0, col=10, action_type="reveal") - result = client.step(action) + result = self.client.step(action) observation = result.observation assert observation.reward < 0, "Should receive negative reward for invalid action" def test_reveal_already_revealed(self): """Test revealing an already revealed cell.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - client.reset() + self.client.reset() # Reveal a cell - try multiple cells to ensure one gets revealed # (some cells might cascade reveal if they have 0 adjacent mines) action = MinesweeperAction(row=2, col=2, action_type="reveal") - result = client.step(action) + result = self.client.step(action) first_reward = result.observation.reward # Make sure the cell was actually revealed (could be revealed by cascade) # If it wasn't revealed successfully, try another cell if result.observation.board[2][2] == -1: action = MinesweeperAction(row=1, col=1, action_type="reveal") - result = client.step(action) + result = self.client.step(action) test_row, test_col = 1, 1 else: test_row, test_col = 2, 2 # Try revealing the same cell again action = MinesweeperAction(row=test_row, col=test_col, action_type="reveal") - result = client.step(action) + result = self.client.step(action) second_reward = result.observation.reward assert second_reward < 0, f"Should receive penalty for revealing already revealed cell, got {second_reward}" def test_game_status_ongoing(self): """Test that game status remains ONGOING during normal play.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - client.reset() + self.client.reset() # Make a few safe moves action = MinesweeperAction(row=0, col=0, action_type="reveal") - result = client.step(action) + result = self.client.step(action) # Game should still be ongoing if we didn't hit a mine or win # Handle both int and enum types for game_status @@ -213,12 +213,11 @@ def test_game_status_ongoing(self): def test_board_cell_values(self): """Test that board cells contain valid values.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - client.reset() + self.client.reset() # Reveal a cell action = MinesweeperAction(row=2, col=2, action_type="reveal") - result = client.step(action) + result = self.client.step(action) observation = result.observation # Check that revealed cells have valid values (0-8 or '*') @@ -233,8 +232,7 @@ def test_board_cell_values(self): def test_metadata_in_observation(self): """Test that observations contain metadata.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - result = client.reset() + result = self.client.reset() observation = result.observation assert hasattr(observation, "metadata"), "Observation should have metadata" @@ -242,8 +240,7 @@ def test_metadata_in_observation(self): def test_multiple_steps(self): """Test taking multiple steps in the environment.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - client.reset() + self.client.reset() # Take several actions actions = [ @@ -253,20 +250,18 @@ def test_multiple_steps(self): ] for action in actions: - result = client.step(action) + result = self.client.step(action) assert isinstance(result.observation, MinesweeperObservation) def test_reset_clears_state(self): """Test that reset properly clears the game state.""" - client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - # First game - client.reset() + self.client.reset() action = MinesweeperAction(row=0, col=0, action_type="flag") - client.step(action) + self.client.step(action) # Reset and check state is cleared - result = client.reset() + result = self.client.reset() observation = result.observation assert observation.flags_placed == 0, "Flags should be cleared after reset" From f94968fca425f67a083dd29980ce0d28748ece0e Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Mon, 23 Feb 2026 08:19:59 -0800 Subject: [PATCH 07/11] Convert minesweeper tests to async to work with async EnvClient Use unittest.IsolatedAsyncioTestCase with async with context managers to work directly with the async MinesweeperEnv client, avoiding the SyncEnvClient wrapper. Each test properly connects and disconnects within a single event loop, preventing session capacity errors. Co-Authored-By: Claude Opus 4.6 --- tests/envs/test_minesweeper_env.py | 346 +++++++++++++++-------------- 1 file changed, 175 insertions(+), 171 deletions(-) diff --git a/tests/envs/test_minesweeper_env.py b/tests/envs/test_minesweeper_env.py index e64d6481d..31c4d9bbc 100644 --- a/tests/envs/test_minesweeper_env.py +++ b/tests/envs/test_minesweeper_env.py @@ -29,7 +29,7 @@ import signal -class TestMinesweeperEnv(unittest.TestCase): +class TestMinesweeperEnv(unittest.IsolatedAsyncioTestCase): """Test cases for the Minesweeper environment.""" server_process = None @@ -67,208 +67,212 @@ def tearDownClass(cls): if stream and not stream.closed: stream.close() - def setUp(self): - """Create a fresh client for each test.""" - self.client = MinesweeperEnv(base_url="http://127.0.0.1:8000") - - def tearDown(self): - """Close the client after each test to free the session slot.""" - self.client.close() - - def test_minesweeper_env_client(self): + async def test_minesweeper_env_client(self): """Test Minesweeper environment client initialization.""" - assert isinstance(self.client, MinesweeperEnv) + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + assert isinstance(client, MinesweeperEnv) - def test_minesweeper_initial_state(self): + async def test_minesweeper_initial_state(self): """Test the initial state after reset.""" - result = self.client.reset() - observation = result.observation - - # Check observation type and attributes - assert isinstance(observation, MinesweeperObservation) - assert isinstance(observation.board, list) - assert isinstance(observation.done, bool) - assert isinstance(observation.reward, float) - assert isinstance(observation.num_mines, int) - assert isinstance(observation.flags_placed, int) - assert isinstance(observation.cells_revealed, int) - # game_status may be int or GameStatus enum due to HTTP serialization - assert isinstance(observation.game_status, (GameStatus, int)) - - # Check initial state values - assert observation.done is False - assert observation.reward == 0.0 - assert observation.flags_placed == 0 - assert observation.cells_revealed == 0 - # Compare with enum value (handles both int and enum) - assert observation.game_status == GameStatus.ONGOING.value or observation.game_status == GameStatus.ONGOING - - # Check board structure (default 5x5) - assert len(observation.board) == 5 # 5 rows - assert all(len(row) == 5 for row in observation.board) # 5 columns - - # Check all cells are initially unrevealed - assert all( - cell == -1 for row in observation.board for cell in row - ), "All cells should be unrevealed (-1) at start" - - def test_reveal_action(self): + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + result = await client.reset() + observation = result.observation + + # Check observation type and attributes + assert isinstance(observation, MinesweeperObservation) + assert isinstance(observation.board, list) + assert isinstance(observation.done, bool) + assert isinstance(observation.reward, float) + assert isinstance(observation.num_mines, int) + assert isinstance(observation.flags_placed, int) + assert isinstance(observation.cells_revealed, int) + # game_status may be int or GameStatus enum due to HTTP serialization + assert isinstance(observation.game_status, (GameStatus, int)) + + # Check initial state values + assert observation.done is False + assert observation.reward == 0.0 + assert observation.flags_placed == 0 + assert observation.cells_revealed == 0 + # Compare with enum value (handles both int and enum) + assert observation.game_status == GameStatus.ONGOING.value or observation.game_status == GameStatus.ONGOING + + # Check board structure (default 5x5) + assert len(observation.board) == 5 # 5 rows + assert all(len(row) == 5 for row in observation.board) # 5 columns + + # Check all cells are initially unrevealed + assert all( + cell == -1 for row in observation.board for cell in row + ), "All cells should be unrevealed (-1) at start" + + async def test_reveal_action(self): """Test revealing a cell.""" - self.client.reset() + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + await client.reset() - # Try revealing a cell - action = MinesweeperAction(row=0, col=0, action_type="reveal") - result = self.client.step(action) - observation = result.observation + # Try revealing a cell + action = MinesweeperAction(row=0, col=0, action_type="reveal") + result = await client.step(action) + observation = result.observation - assert isinstance(observation, MinesweeperObservation) - assert observation.cells_revealed > 0, "At least one cell should be revealed" + assert isinstance(observation, MinesweeperObservation) + assert observation.cells_revealed > 0, "At least one cell should be revealed" - def test_flag_action(self): + async def test_flag_action(self): """Test placing a flag.""" - self.client.reset() + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + await client.reset() - # Place a flag - action = MinesweeperAction(row=1, col=1, action_type="flag") - result = self.client.step(action) - observation = result.observation + # Place a flag + action = MinesweeperAction(row=1, col=1, action_type="flag") + result = await client.step(action) + observation = result.observation - assert isinstance(observation, MinesweeperObservation) - assert observation.flags_placed == 1, "One flag should be placed" - assert observation.board[1][1] == "F", "Cell should show flag marker" + assert isinstance(observation, MinesweeperObservation) + assert observation.flags_placed == 1, "One flag should be placed" + assert observation.board[1][1] == "F", "Cell should show flag marker" - def test_toggle_flag(self): + async def test_toggle_flag(self): """Test toggling a flag on and off.""" - self.client.reset() - - # Place a flag - action = MinesweeperAction(row=2, col=2, action_type="flag") - result = self.client.step(action) - observation = result.observation - assert observation.flags_placed == 1 - - # Remove the flag - action = MinesweeperAction(row=2, col=2, action_type="flag") - result = self.client.step(action) - observation = result.observation - assert observation.flags_placed == 0, "Flag should be removed" - - def test_invalid_position(self): + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + await client.reset() + + # Place a flag + action = MinesweeperAction(row=2, col=2, action_type="flag") + result = await client.step(action) + observation = result.observation + assert observation.flags_placed == 1 + + # Remove the flag + action = MinesweeperAction(row=2, col=2, action_type="flag") + result = await client.step(action) + observation = result.observation + assert observation.flags_placed == 0, "Flag should be removed" + + async def test_invalid_position(self): """Test action with invalid position.""" - self.client.reset() + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + await client.reset() - # Try invalid row - action = MinesweeperAction(row=10, col=0, action_type="reveal") - result = self.client.step(action) - observation = result.observation + # Try invalid row + action = MinesweeperAction(row=10, col=0, action_type="reveal") + result = await client.step(action) + observation = result.observation - assert observation.reward < 0, "Should receive negative reward for invalid action" + assert observation.reward < 0, "Should receive negative reward for invalid action" - # Try invalid column - action = MinesweeperAction(row=0, col=10, action_type="reveal") - result = self.client.step(action) - observation = result.observation + # Try invalid column + action = MinesweeperAction(row=0, col=10, action_type="reveal") + result = await client.step(action) + observation = result.observation - assert observation.reward < 0, "Should receive negative reward for invalid action" + assert observation.reward < 0, "Should receive negative reward for invalid action" - def test_reveal_already_revealed(self): + async def test_reveal_already_revealed(self): """Test revealing an already revealed cell.""" - self.client.reset() - - # Reveal a cell - try multiple cells to ensure one gets revealed - # (some cells might cascade reveal if they have 0 adjacent mines) - action = MinesweeperAction(row=2, col=2, action_type="reveal") - result = self.client.step(action) - first_reward = result.observation.reward - - # Make sure the cell was actually revealed (could be revealed by cascade) - # If it wasn't revealed successfully, try another cell - if result.observation.board[2][2] == -1: - action = MinesweeperAction(row=1, col=1, action_type="reveal") - result = self.client.step(action) - test_row, test_col = 1, 1 - else: - test_row, test_col = 2, 2 - - # Try revealing the same cell again - action = MinesweeperAction(row=test_row, col=test_col, action_type="reveal") - result = self.client.step(action) - second_reward = result.observation.reward - - assert second_reward < 0, f"Should receive penalty for revealing already revealed cell, got {second_reward}" - - def test_game_status_ongoing(self): + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + await client.reset() + + # Reveal a cell - try multiple cells to ensure one gets revealed + # (some cells might cascade reveal if they have 0 adjacent mines) + action = MinesweeperAction(row=2, col=2, action_type="reveal") + result = await client.step(action) + first_reward = result.observation.reward + + # Make sure the cell was actually revealed (could be revealed by cascade) + # If it wasn't revealed successfully, try another cell + if result.observation.board[2][2] == -1: + action = MinesweeperAction(row=1, col=1, action_type="reveal") + result = await client.step(action) + test_row, test_col = 1, 1 + else: + test_row, test_col = 2, 2 + + # Try revealing the same cell again + action = MinesweeperAction(row=test_row, col=test_col, action_type="reveal") + result = await client.step(action) + second_reward = result.observation.reward + + assert second_reward < 0, f"Should receive penalty for revealing already revealed cell, got {second_reward}" + + async def test_game_status_ongoing(self): """Test that game status remains ONGOING during normal play.""" - self.client.reset() + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + await client.reset() - # Make a few safe moves - action = MinesweeperAction(row=0, col=0, action_type="reveal") - result = self.client.step(action) + # Make a few safe moves + action = MinesweeperAction(row=0, col=0, action_type="reveal") + result = await client.step(action) - # Game should still be ongoing if we didn't hit a mine or win - # Handle both int and enum types for game_status - if (result.observation.game_status == GameStatus.ONGOING.value or - result.observation.game_status == GameStatus.ONGOING): - assert result.observation.done is False + # Game should still be ongoing if we didn't hit a mine or win + # Handle both int and enum types for game_status + if (result.observation.game_status == GameStatus.ONGOING.value or + result.observation.game_status == GameStatus.ONGOING): + assert result.observation.done is False - def test_board_cell_values(self): + async def test_board_cell_values(self): """Test that board cells contain valid values.""" - self.client.reset() - - # Reveal a cell - action = MinesweeperAction(row=2, col=2, action_type="reveal") - result = self.client.step(action) - observation = result.observation - - # Check that revealed cells have valid values (0-8 or '*') - for row in observation.board: - for cell in row: - assert ( - cell == -1 # Unrevealed - or cell == "F" # Flagged - or cell == "*" # Mine (if revealed) - or (isinstance(cell, int) and 0 <= cell <= 8) # Number of adjacent mines - ), f"Invalid cell value: {cell}" - - def test_metadata_in_observation(self): + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + await client.reset() + + # Reveal a cell + action = MinesweeperAction(row=2, col=2, action_type="reveal") + result = await client.step(action) + observation = result.observation + + # Check that revealed cells have valid values (0-8 or '*') + for row in observation.board: + for cell in row: + assert ( + cell == -1 # Unrevealed + or cell == "F" # Flagged + or cell == "*" # Mine (if revealed) + or (isinstance(cell, int) and 0 <= cell <= 8) # Number of adjacent mines + ), f"Invalid cell value: {cell}" + + async def test_metadata_in_observation(self): """Test that observations contain metadata.""" - result = self.client.reset() - observation = result.observation + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + result = await client.reset() + observation = result.observation - assert hasattr(observation, "metadata"), "Observation should have metadata" - assert isinstance(observation.metadata, dict), "Metadata should be a dictionary" + assert hasattr(observation, "metadata"), "Observation should have metadata" + assert isinstance(observation.metadata, dict), "Metadata should be a dictionary" - def test_multiple_steps(self): + async def test_multiple_steps(self): """Test taking multiple steps in the environment.""" - self.client.reset() + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + await client.reset() - # Take several actions - actions = [ - MinesweeperAction(row=0, col=0, action_type="reveal"), - MinesweeperAction(row=0, col=1, action_type="flag"), - MinesweeperAction(row=1, col=0, action_type="reveal"), - ] + # Take several actions + actions = [ + MinesweeperAction(row=0, col=0, action_type="reveal"), + MinesweeperAction(row=0, col=1, action_type="flag"), + MinesweeperAction(row=1, col=0, action_type="reveal"), + ] - for action in actions: - result = self.client.step(action) - assert isinstance(result.observation, MinesweeperObservation) + for action in actions: + result = await client.step(action) + assert isinstance(result.observation, MinesweeperObservation) - def test_reset_clears_state(self): + async def test_reset_clears_state(self): """Test that reset properly clears the game state.""" - # First game - self.client.reset() - action = MinesweeperAction(row=0, col=0, action_type="flag") - self.client.step(action) - - # Reset and check state is cleared - result = self.client.reset() - observation = result.observation - - assert observation.flags_placed == 0, "Flags should be cleared after reset" - assert observation.cells_revealed == 0, "Revealed cells should be cleared after reset" - # Compare with enum value (handles both int and enum) - assert (observation.game_status == GameStatus.ONGOING.value or - observation.game_status == GameStatus.ONGOING), "Game should be ongoing after reset" + async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: + # First game + await client.reset() + action = MinesweeperAction(row=0, col=0, action_type="flag") + await client.step(action) + + # Reset and check state is cleared + result = await client.reset() + observation = result.observation + + assert observation.flags_placed == 0, "Flags should be cleared after reset" + assert observation.cells_revealed == 0, "Revealed cells should be cleared after reset" + # Compare with enum value (handles both int and enum) + assert (observation.game_status == GameStatus.ONGOING.value or + observation.game_status == GameStatus.ONGOING), "Game should be ongoing after reset" if __name__ == "__main__": From 1dde1812072da01bc12313f97a81397fcf113c1e Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Mon, 23 Feb 2026 08:54:36 -0800 Subject: [PATCH 08/11] Fix IndexError: auto-reset environment in __init__ so board is playable immediately Initialize _mine_counts with proper dimensions and call self.reset() in __init__ to ensure the board is fully set up before any step call. Prevents IndexError in _reveal_recursive when the web interface calls step on a freshly constructed environment instance. Co-Authored-By: Claude Opus 4.6 --- src/envs/minesweeper_env/server/minesweeper_environment.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/envs/minesweeper_env/server/minesweeper_environment.py b/src/envs/minesweeper_env/server/minesweeper_environment.py index 6cbab3546..9d0d47cf3 100644 --- a/src/envs/minesweeper_env/server/minesweeper_environment.py +++ b/src/envs/minesweeper_env/server/minesweeper_environment.py @@ -72,9 +72,12 @@ def __init__(self, height: int = 5, width: int = 5, num_mines: int = 5): self._mine_positions: Set[Tuple[int, int]] = set() self._revealed_cells: Set[Tuple[int, int]] = set() self._flags_placed: Set[Tuple[int, int]] = set() - self._mine_counts: List[List[int]] = [] + self._mine_counts: List[List[int]] = [[0 for _ in range(width)] for _ in range(height)] self._game_status = GameStatus.ONGOING + # Auto-reset so the board is playable immediately + self.reset() + def reset(self) -> MinesweeperObservation: """ Reset the environment and starts a new game. From fa03d7127c358909ea200fccc68f70ba5e11f20e Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Wed, 13 May 2026 08:59:37 -0700 Subject: [PATCH 09/11] Address PR review: remove no-op try/except shims and tidy Tier 1 issues - docker-build.yml: set explicit context: . for minesweeper-env (Dockerfile COPY paths assume repo root) - Remove duplicate try/except ImportError blocks where both branches imported the same path (no-op fallback) in app.py, models.py, minesweeper_environment.py - client.py: only the models import differs between in-repo/standalone, so flatten core imports and keep relative/absolute fallback only for models - Drop os.environ["PYTHONPATH"] mutation and redundant sys.path.insert in tests; PYTHONPATH=src:envs is provided by the test runner - Remove implicit self.reset() in MinesweeperEnvironment.__init__; rely on explicit reset() per Gymnasium convention --- .github/workflows/docker-build.yml | 2 +- src/envs/minesweeper_env/client.py | 13 +++++------ src/envs/minesweeper_env/models.py | 8 +------ src/envs/minesweeper_env/server/app.py | 8 +------ .../server/minesweeper_environment.py | 16 +++----------- tests/envs/test_minesweeper_env.py | 22 +++++++------------ 6 files changed, 19 insertions(+), 50 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index a088f9a77..4840cef03 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -88,7 +88,7 @@ jobs: dockerfile: envs/connect4_env/server/Dockerfile - name: minesweeper-env dockerfile: src/envs/minesweeper_env/server/Dockerfile - context: envs/connect4_env + context: . - name: chess-env dockerfile: envs/chess_env/server/Dockerfile context: envs/chess_env diff --git a/src/envs/minesweeper_env/client.py b/src/envs/minesweeper_env/client.py index 4b08bd086..8d4ef5d68 100644 --- a/src/envs/minesweeper_env/client.py +++ b/src/envs/minesweeper_env/client.py @@ -13,18 +13,15 @@ from typing import Any, Dict -# Support both in-repo and standalone imports +from openenv.core.client_types import StepResult +from openenv.core.env_client import EnvClient +from openenv.core.env_server.types import State + try: # In-repo imports (when running from OpenEnv repository) - from openenv.core.client_types import StepResult - from openenv.core.env_server.types import State - from openenv.core.env_client import EnvClient from .models import MinesweeperAction, MinesweeperObservation except ImportError: - # Standalone imports (when environment is standalone with openenv from pip) - from openenv.core.client_types import StepResult - from openenv.core.env_server.types import State - from openenv.core.env_client import EnvClient + # Standalone imports (when this module is imported as a top-level package) from models import MinesweeperAction, MinesweeperObservation diff --git a/src/envs/minesweeper_env/models.py b/src/envs/minesweeper_env/models.py index 8f8959d1a..9ba181d90 100644 --- a/src/envs/minesweeper_env/models.py +++ b/src/envs/minesweeper_env/models.py @@ -15,13 +15,7 @@ from typing import List, Any, Set, Tuple from pydantic import Field, BaseModel -# Support both in-repo and standalone imports -try: - # In-repo imports (when running from OpenEnv repository) - from openenv.core.env_server.types import Action, Observation -except ImportError: - # Standalone imports (when environment is standalone with openenv from pip) - from openenv.core.env_server.types import Action, Observation +from openenv.core.env_server.types import Action, Observation class GameStatus(Enum): diff --git a/src/envs/minesweeper_env/server/app.py b/src/envs/minesweeper_env/server/app.py index 96d1ec9da..b9ef1d5f9 100644 --- a/src/envs/minesweeper_env/server/app.py +++ b/src/envs/minesweeper_env/server/app.py @@ -6,13 +6,7 @@ """FastAPI application for the Minesweeper Environment.""" -# Support both in-repo and standalone imports -try: - # In-repo imports (when running from OpenEnv repository) - from openenv.core.env_server import create_app -except ImportError: - # Standalone imports (when environment is standalone with openenv from pip) - from openenv.core.env_server import create_app +from openenv.core.env_server import create_app from ..models import MinesweeperAction, MinesweeperObservation from .minesweeper_environment import MinesweeperEnvironment diff --git a/src/envs/minesweeper_env/server/minesweeper_environment.py b/src/envs/minesweeper_env/server/minesweeper_environment.py index 9d0d47cf3..1ca6ec802 100644 --- a/src/envs/minesweeper_env/server/minesweeper_environment.py +++ b/src/envs/minesweeper_env/server/minesweeper_environment.py @@ -14,6 +14,9 @@ from typing import Any, Dict, List, Optional, Set, Tuple from uuid import uuid4 +from openenv.core.env_server.interfaces import Environment +from openenv.core.env_server.types import State + from ..models import ( MinesweeperAction, MinesweeperObservation, @@ -21,16 +24,6 @@ MinesweeperState, ) -# Support both in-repo and standalone imports -try: - # In-repo imports (when running from OpenEnv repository) - from openenv.core.env_server.interfaces import Environment - from openenv.core.env_server.types import State -except ImportError: - # Standalone imports (when environment is standalone with openenv from pip) - from openenv.core.env_server.interfaces import Environment - from openenv.core.env_server.types import State - class MinesweeperEnvironment(Environment): """ @@ -75,9 +68,6 @@ def __init__(self, height: int = 5, width: int = 5, num_mines: int = 5): self._mine_counts: List[List[int]] = [[0 for _ in range(width)] for _ in range(height)] self._game_status = GameStatus.ONGOING - # Auto-reset so the board is playable immediately - self.reset() - def reset(self) -> MinesweeperObservation: """ Reset the environment and starts a new game. diff --git a/tests/envs/test_minesweeper_env.py b/tests/envs/test_minesweeper_env.py index 31c4d9bbc..22e241caa 100644 --- a/tests/envs/test_minesweeper_env.py +++ b/tests/envs/test_minesweeper_env.py @@ -6,27 +6,21 @@ """Test suite for Minesweeper Environment.""" -import sys import os -from pathlib import Path +import signal +import subprocess +import sys +import time +import unittest -# Add src to PYTHONPATH for proper imports -ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -SRC_PATH = os.path.join(ROOT_DIR, "src") -sys.path.insert(0, SRC_PATH) -os.environ["PYTHONPATH"] = SRC_PATH +import requests from envs.minesweeper_env import ( - MinesweeperAction, - MinesweeperObservation, GameStatus, + MinesweeperAction, MinesweeperEnv, + MinesweeperObservation, ) -import subprocess -import unittest -import time -import requests -import signal class TestMinesweeperEnv(unittest.IsolatedAsyncioTestCase): From 84a565d7db18d9a68bea98310d930cdaaa9f7e25 Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Wed, 13 May 2026 09:05:54 -0700 Subject: [PATCH 10/11] Move minesweeper env to envs/, align with project conventions Tier 2 alignment fixes from PR review: - Move src/envs/minesweeper_env/ -> envs/minesweeper_env/ so the env lives alongside every other environment in the repo (echo_env, connect4_env, etc.) - Rewrite Dockerfile to match the connect4/echo pattern: build context is the env directory and uses 'uv sync' rather than copying repo-root core sources - Update docker-build.yml to set the new path + 'context: envs/minesweeper_env' - Tidy pyproject.toml to match the connect4_env layout (package name minesweeper_env, script entry, package-dir mapping) - README: update docker build invocation to the new path - Make MinesweeperEnvironment.get_full_state private (_get_full_state) since it exposes mine locations and must never be reachable by an agent --- .github/workflows/docker-build.yml | 4 +- {src/envs => envs}/minesweeper_env/README.md | 4 +- .../envs => envs}/minesweeper_env/__init__.py | 6 +- {src/envs => envs}/minesweeper_env/client.py | 0 {src/envs => envs}/minesweeper_env/models.py | 0 .../minesweeper_env/openenv.yaml | 0 envs/minesweeper_env/pyproject.toml | 36 ++++++++++ envs/minesweeper_env/server/Dockerfile | 54 +++++++++++++++ .../minesweeper_env/server/__init__.py | 0 .../minesweeper_env/server/app.py | 0 .../minesweeper_env/server/build_docker.sh | 0 .../server/minesweeper_environment.py | 7 +- src/envs/minesweeper_env/pyproject.toml | 32 --------- src/envs/minesweeper_env/server/Dockerfile | 67 ------------------- 14 files changed, 102 insertions(+), 108 deletions(-) rename {src/envs => envs}/minesweeper_env/README.md (96%) rename {src/envs => envs}/minesweeper_env/__init__.py (50%) rename {src/envs => envs}/minesweeper_env/client.py (100%) rename {src/envs => envs}/minesweeper_env/models.py (100%) rename {src/envs => envs}/minesweeper_env/openenv.yaml (100%) create mode 100644 envs/minesweeper_env/pyproject.toml create mode 100644 envs/minesweeper_env/server/Dockerfile rename {src/envs => envs}/minesweeper_env/server/__init__.py (100%) rename {src/envs => envs}/minesweeper_env/server/app.py (100%) rename {src/envs => envs}/minesweeper_env/server/build_docker.sh (100%) rename {src/envs => envs}/minesweeper_env/server/minesweeper_environment.py (97%) delete mode 100644 src/envs/minesweeper_env/pyproject.toml delete mode 100644 src/envs/minesweeper_env/server/Dockerfile diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 4840cef03..01ca48e9e 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -87,8 +87,8 @@ jobs: - name: connect4_env dockerfile: envs/connect4_env/server/Dockerfile - name: minesweeper-env - dockerfile: src/envs/minesweeper_env/server/Dockerfile - context: . + dockerfile: envs/minesweeper_env/server/Dockerfile + context: envs/minesweeper_env - name: chess-env dockerfile: envs/chess_env/server/Dockerfile context: envs/chess_env diff --git a/src/envs/minesweeper_env/README.md b/envs/minesweeper_env/README.md similarity index 96% rename from src/envs/minesweeper_env/README.md rename to envs/minesweeper_env/README.md index c90f43417..7b7a6749d 100644 --- a/src/envs/minesweeper_env/README.md +++ b/envs/minesweeper_env/README.md @@ -42,13 +42,13 @@ finally: Build the Docker image from the project root: ```bash -docker build -t minesweeper-env:latest -f src/envs/minesweeper_env/server/Dockerfile . +docker build -t minesweeper-env:latest -f envs/minesweeper_env/server/Dockerfile envs/minesweeper_env ``` Or use the build script: ```bash -cd src/envs/minesweeper_env/server +cd envs/minesweeper_env/server ./build_docker.sh latest ``` diff --git a/src/envs/minesweeper_env/__init__.py b/envs/minesweeper_env/__init__.py similarity index 50% rename from src/envs/minesweeper_env/__init__.py rename to envs/minesweeper_env/__init__.py index 4048c243e..c9d2401be 100644 --- a/src/envs/minesweeper_env/__init__.py +++ b/envs/minesweeper_env/__init__.py @@ -4,10 +4,10 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""Minesweeper Environment - A simple test environment for HTTP server.""" +"""Minesweeper Environment - a grid-based puzzle game for OpenEnv.""" from .client import MinesweeperEnv -from .models import MinesweeperAction, MinesweeperObservation, GameStatus +from .models import GameStatus, MinesweeperAction, MinesweeperObservation -__all__ = ["MinesweeperAction", "MinesweeperObservation", "MinesweeperEnv", "GameStatus"] +__all__ = ["GameStatus", "MinesweeperAction", "MinesweeperEnv", "MinesweeperObservation"] diff --git a/src/envs/minesweeper_env/client.py b/envs/minesweeper_env/client.py similarity index 100% rename from src/envs/minesweeper_env/client.py rename to envs/minesweeper_env/client.py diff --git a/src/envs/minesweeper_env/models.py b/envs/minesweeper_env/models.py similarity index 100% rename from src/envs/minesweeper_env/models.py rename to envs/minesweeper_env/models.py diff --git a/src/envs/minesweeper_env/openenv.yaml b/envs/minesweeper_env/openenv.yaml similarity index 100% rename from src/envs/minesweeper_env/openenv.yaml rename to envs/minesweeper_env/openenv.yaml diff --git a/envs/minesweeper_env/pyproject.toml b/envs/minesweeper_env/pyproject.toml new file mode 100644 index 000000000..aeaafb585 --- /dev/null +++ b/envs/minesweeper_env/pyproject.toml @@ -0,0 +1,36 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openenv-minesweeper-env" +version = "0.1.0" +description = "Minesweeper Environment for OpenEnv - grid-based puzzle game" +requires-python = ">=3.10" +dependencies = [ + "openenv-core[core]>=0.2.2", + "fastapi>=0.115.0", + "pydantic>=2.0.0", + "uvicorn>=0.24.0", + "requests>=2.31.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=9.0.3", + "pytest-cov>=4.0.0", +] + +[project.scripts] +server = "minesweeper_env.server.app:main" + +[tool.setuptools] +include-package-data = true +packages = ["minesweeper_env", "minesweeper_env.server"] +package-dir = { "minesweeper_env" = ".", "minesweeper_env.server" = "server" } diff --git a/envs/minesweeper_env/server/Dockerfile b/envs/minesweeper_env/server/Dockerfile new file mode 100644 index 000000000..b744245b2 --- /dev/null +++ b/envs/minesweeper_env/server/Dockerfile @@ -0,0 +1,54 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest +FROM ${BASE_IMAGE} AS builder + +WORKDIR /app + +COPY . /app/env + +WORKDIR /app/env + +RUN if ! command -v uv >/dev/null 2>&1; then \ + curl -LsSf https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx; \ + fi + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +RUN --mount=type=cache,target=/root/.cache/uv \ + if [ -f uv.lock ]; then \ + uv sync --frozen --no-install-project --no-editable; \ + else \ + uv sync --no-install-project --no-editable; \ + fi + +RUN --mount=type=cache,target=/root/.cache/uv \ + if [ -f uv.lock ]; then \ + uv sync --frozen --no-editable; \ + else \ + uv sync --no-editable; \ + fi + +# Final runtime stage +FROM ${BASE_IMAGE} + +WORKDIR /app + +COPY --from=builder /app/env/.venv /app/.venv +COPY --from=builder /app/env /app/env + +ENV PATH="/app/.venv/bin:$PATH" +ENV PYTHONPATH="/app/env:$PYTHONPATH" + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"] diff --git a/src/envs/minesweeper_env/server/__init__.py b/envs/minesweeper_env/server/__init__.py similarity index 100% rename from src/envs/minesweeper_env/server/__init__.py rename to envs/minesweeper_env/server/__init__.py diff --git a/src/envs/minesweeper_env/server/app.py b/envs/minesweeper_env/server/app.py similarity index 100% rename from src/envs/minesweeper_env/server/app.py rename to envs/minesweeper_env/server/app.py diff --git a/src/envs/minesweeper_env/server/build_docker.sh b/envs/minesweeper_env/server/build_docker.sh similarity index 100% rename from src/envs/minesweeper_env/server/build_docker.sh rename to envs/minesweeper_env/server/build_docker.sh diff --git a/src/envs/minesweeper_env/server/minesweeper_environment.py b/envs/minesweeper_env/server/minesweeper_environment.py similarity index 97% rename from src/envs/minesweeper_env/server/minesweeper_environment.py rename to envs/minesweeper_env/server/minesweeper_environment.py index 1ca6ec802..180927b33 100644 --- a/src/envs/minesweeper_env/server/minesweeper_environment.py +++ b/envs/minesweeper_env/server/minesweeper_environment.py @@ -279,9 +279,12 @@ def state(self) -> State: """ return self._state - def get_full_state(self) -> MinesweeperState: + def _get_full_state(self) -> MinesweeperState: """ - Get the full internal state of the Minesweeper environment. + Get the full internal state of the environment (server-side only). + + Exposes the mine layout and is intended for testing / introspection. + Never call this from a code path reachable by an agent. Returns: MinesweeperState representing the full internal state diff --git a/src/envs/minesweeper_env/pyproject.toml b/src/envs/minesweeper_env/pyproject.toml deleted file mode 100644 index 21135d76b..000000000 --- a/src/envs/minesweeper_env/pyproject.toml +++ /dev/null @@ -1,32 +0,0 @@ -[build-system] -requires = ["setuptools>=45", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "openenv-minesweeper" -version = "0.1.0" -description = "Minesweeper Environment for OpenEnv" -requires-python = ">=3.10" -dependencies = [ - "openenv-core>=0.1.0", - "fastapi>=0.115.0", - "uvicorn>=0.24.0", - "pydantic>=2.0.0", - "requests>=2.31.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.0.0", - "pytest-cov>=4.0.0", -] - -[project.scripts] -# Server entry point -enables running via: uv run --project . server -# or: python -m minesweeper.server.app -server = "minesweeper.server.app:main" - -[tool.setuptools] -include-package-data = true -packages = ["minesweeper", "minesweeper.server"] -package-dir = { "minesweeper" = ".", "minesweeper.server" = "server" } diff --git a/src/envs/minesweeper_env/server/Dockerfile b/src/envs/minesweeper_env/server/Dockerfile deleted file mode 100644 index dc1167387..000000000 --- a/src/envs/minesweeper_env/server/Dockerfile +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest -FROM ${BASE_IMAGE} AS builder - -WORKDIR /app - -# Ensure git is available -RUN apt-get update && \ - apt-get install -y --no-install-recommends git && \ - rm -rf /var/lib/apt/lists/* - -# Build argument to control whether we are building standalone or in-repo -ARG BUILD_MODE=in-repo -ARG ENV_NAME=minesweeper - -# Copy openenv-core source (assumes build context is OpenEnv root) -COPY src/pyproject.toml /app/openenv-core/pyproject.toml -COPY src/core /app/openenv-core/core -COPY src/openenv_cli /app/openenv-core/openenv_cli -COPY src/__init__.py /app/openenv-core/__init__.py - -# Copy the environment source code -COPY src/envs/minesweeper_env /app/env - -# For in-repo builds, openenv-core is already in the pyproject.toml dependencies -# For standalone builds, openenv-core will be installed from PyPI via pyproject.toml -WORKDIR /app/env - -# Ensure uv is available -RUN if ! command -v uv > /dev/null 2>&1; then \ - curl -LsSf https://astral.sh/uv/install.sh | sh && \ - mv /root/.local/bin/uv /usr/local/bin/uv && \ - mv /root/.local/bin/uvx /usr/local/bin/uvx; \ - fi - -# Install dependencies and create virtual environment -RUN uv venv /app/env/.venv && \ - uv pip install --python /app/env/.venv/bin/python --no-cache /app/openenv-core && \ - uv pip install --python /app/env/.venv/bin/python --no-cache . - -# Final runtime stage -FROM ${BASE_IMAGE} AS runtime - -WORKDIR /app - -# Copy the built environment from the builder stage -COPY --from=builder /app/env/.venv /app/.venv - -# Copy the environment source code -COPY --from=builder /app/env /app/env - -# Set PATH to use the virtual environment -ENV PATH="/app/.venv/bin:$PATH" - -# Set PYTHONPATH -ENV PYTHONPATH="/app/env:$PYTHONPATH" - -# Health check -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD curl -f http://localhost:8000/health || exit 1 - -# Run the FastAPI server -CMD ["/app/.venv/bin/uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"] From 41836c9d191936864e2882a9fdb53a35cb6a14f0 Mon Sep 17 00:00:00 2001 From: Anusha Acharya Date: Wed, 13 May 2026 21:43:27 -0700 Subject: [PATCH 11/11] Fix win-condition bug and address PR review follow-ups - Guard _check_win_condition against overwriting LOST with WON when a mine reveal pushes total revealed_count to the win threshold; count only non-mine cells. - Type MinesweeperEnvironment generics (Environment[Action, Obs, State]); keep public state as base State so /state cannot leak mine_positions. - Surface action-rejection reasons (already revealed / flagged / can't flag a revealed cell) in observation.metadata["error"]. - De-flake test_reveal_already_revealed (handle the mine-on-first-reveal branch by re-trying candidates instead of asserting on game-over reward). - Add direct unit tests for the win-condition guard and metadata errors. --- envs/minesweeper_env/__init__.py | 8 +- envs/minesweeper_env/client.py | 2 +- envs/minesweeper_env/models.py | 16 +- envs/minesweeper_env/server/__init__.py | 1 - envs/minesweeper_env/server/app.py | 3 +- .../server/minesweeper_environment.py | 119 +++++++----- tests/envs/test_minesweeper_env.py | 179 ++++++++++++++---- 7 files changed, 241 insertions(+), 87 deletions(-) diff --git a/envs/minesweeper_env/__init__.py b/envs/minesweeper_env/__init__.py index c9d2401be..73633caf2 100644 --- a/envs/minesweeper_env/__init__.py +++ b/envs/minesweeper_env/__init__.py @@ -9,5 +9,9 @@ from .client import MinesweeperEnv from .models import GameStatus, MinesweeperAction, MinesweeperObservation -__all__ = ["GameStatus", "MinesweeperAction", "MinesweeperEnv", "MinesweeperObservation"] - +__all__ = [ + "GameStatus", + "MinesweeperAction", + "MinesweeperEnv", + "MinesweeperObservation", +] diff --git a/envs/minesweeper_env/client.py b/envs/minesweeper_env/client.py index 8d4ef5d68..3bc7d0fb8 100644 --- a/envs/minesweeper_env/client.py +++ b/envs/minesweeper_env/client.py @@ -11,7 +11,7 @@ via WebSocket for persistent sessions. """ -from typing import Any, Dict +from typing import Dict from openenv.core.client_types import StepResult from openenv.core.env_client import EnvClient diff --git a/envs/minesweeper_env/models.py b/envs/minesweeper_env/models.py index 9ba181d90..b22e1d0f0 100644 --- a/envs/minesweeper_env/models.py +++ b/envs/minesweeper_env/models.py @@ -12,14 +12,15 @@ """ from enum import Enum -from typing import List, Any, Set, Tuple -from pydantic import Field, BaseModel +from typing import Any, List, Set, Tuple from openenv.core.env_server.types import Action, Observation +from pydantic import BaseModel, Field class GameStatus(Enum): """Status of the Minesweeper game.""" + ONGOING = "ongoing" WON = "won" LOST = "lost" @@ -34,9 +35,12 @@ class MinesweeperAction(Action): col: Column index of the cell to act on (0-indexed). action_type: Type of action - 'reveal' to uncover a cell, 'flag' to place/remove a flag. """ + row: int = Field(..., ge=0, description="Row index of the cell") col: int = Field(..., ge=0, description="Column index of the cell") - action_type: str = Field(..., pattern="^(reveal|flag)$", description="Type of action: 'reveal' or 'flag'") + action_type: str = Field( + ..., pattern="^(reveal|flag)$", description="Type of action: 'reveal' or 'flag'" + ) class MinesweeperObservation(Observation): @@ -56,6 +60,7 @@ class MinesweeperObservation(Observation): cells_revealed: Number of cells that have been revealed so far. game_status: Current status of the game - ongoing, won, or lost. """ + board: List[List[Any]] = Field(default_factory=list, description="2D board state") num_mines: int = Field(..., ge=0, description="Total number of mines") flags_placed: int = Field(..., ge=0, description="Number of flags placed") @@ -90,6 +95,7 @@ class MinesweeperState(BaseModel): mine_counts: 2D list with counts of adjacent mines for each cell. game_status: Current status of the game - ongoing, won, or lost. """ + episode_id: str step_count: int board_height: int @@ -113,11 +119,11 @@ def to_observation(self) -> MinesweeperObservation: for c in range(self.board_width): if (r, c) in self.revealed_cells: if (r, c) in self.mine_locations: - cell_value = '*' # Revealed mine + cell_value = "*" # Revealed mine else: cell_value = self.mine_counts[r][c] # Number of adjacent mines elif (r, c) in self.flags: - cell_value = 'F' # Flagged cell + cell_value = "F" # Flagged cell else: cell_value = -1 # Unrevealed cell row.append(cell_value) diff --git a/envs/minesweeper_env/server/__init__.py b/envs/minesweeper_env/server/__init__.py index 36bb963e8..2fde76979 100644 --- a/envs/minesweeper_env/server/__init__.py +++ b/envs/minesweeper_env/server/__init__.py @@ -9,4 +9,3 @@ from .minesweeper_environment import MinesweeperEnvironment __all__ = ["MinesweeperEnvironment"] - diff --git a/envs/minesweeper_env/server/app.py b/envs/minesweeper_env/server/app.py index b9ef1d5f9..939724dde 100644 --- a/envs/minesweeper_env/server/app.py +++ b/envs/minesweeper_env/server/app.py @@ -17,9 +17,10 @@ MinesweeperEnvironment, MinesweeperAction, MinesweeperObservation, - env_name="minesweeper_env" + env_name="minesweeper_env", ) if __name__ == "__main__": import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/envs/minesweeper_env/server/minesweeper_environment.py b/envs/minesweeper_env/server/minesweeper_environment.py index 180927b33..8677c00e7 100644 --- a/envs/minesweeper_env/server/minesweeper_environment.py +++ b/envs/minesweeper_env/server/minesweeper_environment.py @@ -10,6 +10,7 @@ A Minesweeper game environment where agents must reveal cells and place flags to identify mines on a grid board without triggering any mines. """ + import random from typing import Any, Dict, List, Optional, Set, Tuple from uuid import uuid4 @@ -18,14 +19,16 @@ from openenv.core.env_server.types import State from ..models import ( + GameStatus, MinesweeperAction, MinesweeperObservation, - GameStatus, MinesweeperState, ) -class MinesweeperEnvironment(Environment): +class MinesweeperEnvironment( + Environment[MinesweeperAction, MinesweeperObservation, State] +): """ Minesweeper game environment implementation for Reinforcement Learning. The environment consists of a grid with hidden mines. The agent can reveal cells or place flags. @@ -40,7 +43,7 @@ class MinesweeperEnvironment(Environment): 0-8: number of adjacent mines (if revealed) 'F': flagged cell '*': mine (only revealed if game is lost) - + Example: >>> env = MinesweeperEnvironment(height=5, width=5, num_mines=5) >>> obs = env.reset() @@ -54,6 +57,7 @@ def __init__(self, height: int = 5, width: int = 5, num_mines: int = 5): width: Width of the minesweeper board. num_mines: Number of mines to place on the board. """ + super().__init__() self.height = height self.width = width self.num_mines = num_mines @@ -65,7 +69,9 @@ def __init__(self, height: int = 5, width: int = 5, num_mines: int = 5): self._mine_positions: Set[Tuple[int, int]] = set() self._revealed_cells: Set[Tuple[int, int]] = set() self._flags_placed: Set[Tuple[int, int]] = set() - self._mine_counts: List[List[int]] = [[0 for _ in range(width)] for _ in range(height)] + self._mine_counts: List[List[int]] = [ + [0 for _ in range(width)] for _ in range(height) + ] self._game_status = GameStatus.ONGOING def reset(self) -> MinesweeperObservation: @@ -126,21 +132,25 @@ def step(self, action: MinesweeperAction) -> MinesweeperObservation: # type: ig ) reward = 0.0 + error: Optional[str] = None if action.action_type == "reveal": - reward = self._reveal_cell(row, col) + reward, error = self._reveal_cell(row, col) elif action.action_type == "flag": - reward = self._toggle_flag(row, col) + reward, error = self._toggle_flag(row, col) else: - reward = -0.1 # Invalid action type - + reward = -0.1 + error = f"Unknown action_type: {action.action_type!r}" + self._check_win_condition() + metadata = {"error": error} if error else None return self._create_observation( done=self._game_status != GameStatus.ONGOING, reward=reward, + metadata=metadata, ) - + def _place_mines(self) -> None: """Randomly place mines on the board.""" self._mine_positions.clear() @@ -148,7 +158,7 @@ def _place_mines(self) -> None: r = random.randint(0, self.height - 1) c = random.randint(0, self.width - 1) self._mine_positions.add((r, c)) - + def _compute_mine_counts(self) -> None: """Compute the number of adjacent mines for each cell.""" self._mine_counts = [[0 for _ in range(self.width)] for _ in range(self.height)] @@ -157,7 +167,7 @@ def _compute_mine_counts(self) -> None: if (row, col) not in self._mine_positions: count = self._count_adjacent_mines(row, col) self._mine_counts[row][col] = count - + def _count_adjacent_mines(self, row: int, col: int) -> int: """Count the number of mines adjacent to the given cell.""" count = 0 @@ -169,21 +179,26 @@ def _count_adjacent_mines(self, row: int, col: int) -> int: if self._is_valid_position(r, c) and (r, c) in self._mine_positions: count += 1 return count - - def _reveal_cell(self, row: int, col: int) -> float: - """Reveal the cell at (row, col). Returns the reward for the action.""" - if (row, col) in self._revealed_cells or (row, col) in self._flags_placed: - return -0.05 # Penalty for revealing already revealed or flagged cell + + def _reveal_cell(self, row: int, col: int) -> Tuple[float, Optional[str]]: + """Reveal the cell at (row, col). + + Returns: + (reward, error). ``error`` is a human-readable reason the action was + a no-op (e.g. cell already revealed / flagged) or ``None``. + """ + if (row, col) in self._revealed_cells: + return -0.05, f"Cell ({row}, {col}) is already revealed" + if (row, col) in self._flags_placed: + return -0.05, f"Cell ({row}, {col}) is flagged; remove the flag first" if (row, col) in self._mine_positions: self._game_status = GameStatus.LOST self._revealed_cells.add((row, col)) - return -10.0 # Penalty for hitting a mine + return -10.0, None - # Reveal the cell and potentially adjacent cells if count is 0 self._reveal_recursive(row, col) - - return 1.0 # Small reward for safe reveal + return 1.0, None def _reveal_recursive(self, row: int, col: int) -> None: """Recursively reveal cells with 0 adjacent mines.""" @@ -203,32 +218,43 @@ def _reveal_recursive(self, row: int, col: int) -> None: if dr == 0 and dc == 0: continue self._reveal_recursive(row + dr, col + dc) - - def _toggle_flag(self, row: int, col: int) -> float: - """Toggle a flag on the cell at (row, col). Returns the reward for the action.""" + + def _toggle_flag(self, row: int, col: int) -> Tuple[float, Optional[str]]: + """Toggle a flag on the cell at (row, col). + + Returns: + (reward, error). ``error`` is set when the action is rejected + (e.g. trying to flag a revealed cell). + """ if (row, col) in self._revealed_cells: - return -0.05 # Penalty for flagging a revealed cell + return -0.05, f"Cannot flag revealed cell ({row}, {col})" if (row, col) in self._flags_placed: self._flags_placed.remove((row, col)) - return 0.0 # No penalty for removing a flag - else: - self._flags_placed.add((row, col)) - if (row, col) in self._mine_positions: - return 0.5 # Small reward for correctly flagging a mine - return 0.0 # No reward for flagging a non-mine cell - + return 0.0, None + + self._flags_placed.add((row, col)) + if (row, col) in self._mine_positions: + return 0.5, None + return 0.0, None + def _check_win_condition(self) -> None: - """Check if the game has been won.""" - total_cells = self.height * self.width - revealed_count = len(self._revealed_cells) - if revealed_count == total_cells - self.num_mines: + """Check if the game has been won. + + Only counts non-mine cells: hitting a mine adds it to ``_revealed_cells`` + but must never satisfy the win threshold (which would otherwise overwrite + a freshly-set LOST status on a near-complete board). + """ + if self._game_status != GameStatus.ONGOING: + return + safe_revealed = len(self._revealed_cells - self._mine_positions) + if safe_revealed == self.height * self.width - self.num_mines: self._game_status = GameStatus.WON - + def _is_valid_position(self, row: int, col: int) -> bool: """Check if the given (row, col) is within board bounds.""" return 0 <= row < self.height and 0 <= col < self.width - + def _create_observation( self, done: bool, @@ -249,11 +275,11 @@ def _create_observation( for c in range(self.width): if (r, c) in self._revealed_cells: if (r, c) in self._mine_positions: - row.append('*') + row.append("*") else: row.append(self._mine_counts[r][c]) elif (r, c) in self._flags_placed: - row.append('F') + row.append("F") else: row.append(-1) board.append(row) @@ -300,7 +326,7 @@ def _get_full_state(self) -> MinesweeperState: mine_counts=self._mine_counts, game_status=self._game_status, ) - + def get_legal_actions(self) -> List[MinesweeperAction]: """ Get the list of legal actions available in the current state. @@ -316,8 +342,15 @@ def get_legal_actions(self) -> List[MinesweeperAction]: for r in range(self.height): for c in range(self.width): - if (r, c) not in self._revealed_cells and (r, c) not in self._flags_placed: - legal_actions.append(MinesweeperAction(row=r, col=c, action_type="reveal")) + if (r, c) not in self._revealed_cells and ( + r, + c, + ) not in self._flags_placed: + legal_actions.append( + MinesweeperAction(row=r, col=c, action_type="reveal") + ) if (r, c) not in self._revealed_cells: - legal_actions.append(MinesweeperAction(row=r, col=c, action_type="flag")) + legal_actions.append( + MinesweeperAction(row=r, col=c, action_type="flag") + ) return legal_actions diff --git a/tests/envs/test_minesweeper_env.py b/tests/envs/test_minesweeper_env.py index 22e241caa..76368a057 100644 --- a/tests/envs/test_minesweeper_env.py +++ b/tests/envs/test_minesweeper_env.py @@ -14,13 +14,13 @@ import unittest import requests - from envs.minesweeper_env import ( GameStatus, MinesweeperAction, MinesweeperEnv, MinesweeperObservation, ) +from envs.minesweeper_env.server.minesweeper_environment import MinesweeperEnvironment class TestMinesweeperEnv(unittest.IsolatedAsyncioTestCase): @@ -57,7 +57,11 @@ def tearDownClass(cls): except subprocess.TimeoutExpired: os.kill(cls.server_process.pid, signal.SIGKILL) - for stream in [cls.server_process.stdin, cls.server_process.stdout, cls.server_process.stderr]: + for stream in [ + cls.server_process.stdin, + cls.server_process.stdout, + cls.server_process.stderr, + ]: if stream and not stream.closed: stream.close() @@ -89,16 +93,19 @@ async def test_minesweeper_initial_state(self): assert observation.flags_placed == 0 assert observation.cells_revealed == 0 # Compare with enum value (handles both int and enum) - assert observation.game_status == GameStatus.ONGOING.value or observation.game_status == GameStatus.ONGOING + assert ( + observation.game_status == GameStatus.ONGOING.value + or observation.game_status == GameStatus.ONGOING + ) # Check board structure (default 5x5) assert len(observation.board) == 5 # 5 rows assert all(len(row) == 5 for row in observation.board) # 5 columns # Check all cells are initially unrevealed - assert all( - cell == -1 for row in observation.board for cell in row - ), "All cells should be unrevealed (-1) at start" + assert all(cell == -1 for row in observation.board for cell in row), ( + "All cells should be unrevealed (-1) at start" + ) async def test_reveal_action(self): """Test revealing a cell.""" @@ -111,7 +118,9 @@ async def test_reveal_action(self): observation = result.observation assert isinstance(observation, MinesweeperObservation) - assert observation.cells_revealed > 0, "At least one cell should be revealed" + assert observation.cells_revealed > 0, ( + "At least one cell should be revealed" + ) async def test_flag_action(self): """Test placing a flag.""" @@ -154,41 +163,50 @@ async def test_invalid_position(self): result = await client.step(action) observation = result.observation - assert observation.reward < 0, "Should receive negative reward for invalid action" + assert observation.reward < 0, ( + "Should receive negative reward for invalid action" + ) # Try invalid column action = MinesweeperAction(row=0, col=10, action_type="reveal") result = await client.step(action) observation = result.observation - assert observation.reward < 0, "Should receive negative reward for invalid action" + assert observation.reward < 0, ( + "Should receive negative reward for invalid action" + ) async def test_reveal_already_revealed(self): """Test revealing an already revealed cell.""" async with MinesweeperEnv(base_url="http://127.0.0.1:8000") as client: await client.reset() - # Reveal a cell - try multiple cells to ensure one gets revealed - # (some cells might cascade reveal if they have 0 adjacent mines) - action = MinesweeperAction(row=2, col=2, action_type="reveal") - result = await client.step(action) - first_reward = result.observation.reward - - # Make sure the cell was actually revealed (could be revealed by cascade) - # If it wasn't revealed successfully, try another cell - if result.observation.board[2][2] == -1: - action = MinesweeperAction(row=1, col=1, action_type="reveal") + # Find a safe revealed cell to re-target. Mines end the game, so + # we keep trying until we land on a non-mine revealed cell. + candidates = [(2, 2), (1, 1), (0, 0), (4, 4), (3, 3)] + test_row, test_col = None, None + for r, c in candidates: + action = MinesweeperAction(row=r, col=c, action_type="reveal") result = await client.step(action) - test_row, test_col = 1, 1 - else: - test_row, test_col = 2, 2 - - # Try revealing the same cell again + ongoing = ( + result.observation.game_status == GameStatus.ONGOING.value + or result.observation.game_status == GameStatus.ONGOING + ) + if ongoing and result.observation.board[r][c] != -1: + test_row, test_col = r, c + break + if not ongoing: + await client.reset() + + assert test_row is not None, "Could not reveal a safe cell across resets" + + # Re-revealing the same cell should be penalised. action = MinesweeperAction(row=test_row, col=test_col, action_type="reveal") result = await client.step(action) - second_reward = result.observation.reward - - assert second_reward < 0, f"Should receive penalty for revealing already revealed cell, got {second_reward}" + assert result.observation.reward < 0, ( + f"Should receive penalty for revealing already revealed cell, " + f"got {result.observation.reward}" + ) async def test_game_status_ongoing(self): """Test that game status remains ONGOING during normal play.""" @@ -201,8 +219,10 @@ async def test_game_status_ongoing(self): # Game should still be ongoing if we didn't hit a mine or win # Handle both int and enum types for game_status - if (result.observation.game_status == GameStatus.ONGOING.value or - result.observation.game_status == GameStatus.ONGOING): + if ( + result.observation.game_status == GameStatus.ONGOING.value + or result.observation.game_status == GameStatus.ONGOING + ): assert result.observation.done is False async def test_board_cell_values(self): @@ -222,7 +242,9 @@ async def test_board_cell_values(self): cell == -1 # Unrevealed or cell == "F" # Flagged or cell == "*" # Mine (if revealed) - or (isinstance(cell, int) and 0 <= cell <= 8) # Number of adjacent mines + or ( + isinstance(cell, int) and 0 <= cell <= 8 + ) # Number of adjacent mines ), f"Invalid cell value: {cell}" async def test_metadata_in_observation(self): @@ -232,7 +254,9 @@ async def test_metadata_in_observation(self): observation = result.observation assert hasattr(observation, "metadata"), "Observation should have metadata" - assert isinstance(observation.metadata, dict), "Metadata should be a dictionary" + assert isinstance(observation.metadata, dict), ( + "Metadata should be a dictionary" + ) async def test_multiple_steps(self): """Test taking multiple steps in the environment.""" @@ -263,10 +287,97 @@ async def test_reset_clears_state(self): observation = result.observation assert observation.flags_placed == 0, "Flags should be cleared after reset" - assert observation.cells_revealed == 0, "Revealed cells should be cleared after reset" + assert observation.cells_revealed == 0, ( + "Revealed cells should be cleared after reset" + ) # Compare with enum value (handles both int and enum) - assert (observation.game_status == GameStatus.ONGOING.value or - observation.game_status == GameStatus.ONGOING), "Game should be ongoing after reset" + assert ( + observation.game_status == GameStatus.ONGOING.value + or observation.game_status == GameStatus.ONGOING + ), "Game should be ongoing after reset" + + +class TestMinesweeperEnvironmentLogic(unittest.TestCase): + """Direct unit tests against MinesweeperEnvironment (no HTTP server).""" + + def test_hitting_last_mine_does_not_overwrite_lost_with_won(self): + """Revealing a mine must not be reclassified as a win, even if the + revealed-cell count happens to equal total_cells - num_mines.""" + # 2x2 with 2 mines: safe = 2, mines = 2. + # Pre-reveal 1 safe cell. Then reveal a mine — _revealed_cells grows + # to 2, which equals total(4) - mines(2). The buggy check used to + # count the mine and overwrite LOST with WON. + env = MinesweeperEnvironment(height=2, width=2, num_mines=2) + env.reset() + + env._mine_positions = {(0, 1), (1, 1)} + env._compute_mine_counts() + env._revealed_cells = {(0, 0)} + env._flags_placed = set() + env._game_status = GameStatus.ONGOING + + obs = env.step(MinesweeperAction(row=0, col=1, action_type="reveal")) + + assert env._game_status == GameStatus.LOST, ( + f"Game must be LOST after revealing a mine, got {env._game_status}" + ) + assert obs.game_status == GameStatus.LOST + assert obs.done is True + assert obs.reward == -10.0 + + def test_revealing_last_safe_cell_wins(self): + """The normal win path still works after the LOST guard.""" + env = MinesweeperEnvironment(height=2, width=2, num_mines=1) + env.reset() + + env._mine_positions = {(1, 1)} + env._compute_mine_counts() + env._revealed_cells = {(0, 0), (0, 1)} + env._flags_placed = set() + env._game_status = GameStatus.ONGOING + + obs = env.step(MinesweeperAction(row=1, col=0, action_type="reveal")) + + assert env._game_status == GameStatus.WON + assert obs.game_status == GameStatus.WON + assert obs.done is True + + def test_revealing_flagged_cell_returns_error_metadata(self): + """A reject reason for a no-op action is surfaced in metadata.error.""" + env = MinesweeperEnvironment(height=3, width=3, num_mines=1) + env.reset() + + env._mine_positions = {(2, 2)} + env._compute_mine_counts() + env._revealed_cells = set() + env._flags_placed = {(0, 0)} + env._game_status = GameStatus.ONGOING + + obs = env.step(MinesweeperAction(row=0, col=0, action_type="reveal")) + + assert obs.reward == -0.05 + assert obs.metadata.get("error"), ( + "Expected metadata.error explaining the rejection" + ) + assert "flag" in obs.metadata["error"].lower() + + def test_flagging_revealed_cell_returns_error_metadata(self): + """Flagging an already-revealed cell is rejected with an error message.""" + env = MinesweeperEnvironment(height=3, width=3, num_mines=1) + env.reset() + + env._mine_positions = {(2, 2)} + env._compute_mine_counts() + env._revealed_cells = {(0, 0)} + env._flags_placed = set() + env._game_status = GameStatus.ONGOING + + obs = env.step(MinesweeperAction(row=0, col=0, action_type="flag")) + + assert obs.reward == -0.05 + assert obs.metadata.get("error"), ( + "Expected metadata.error explaining the rejection" + ) if __name__ == "__main__":