From 3b0284fa307aa5f8d3c34e78867cfe281e93fa9e Mon Sep 17 00:00:00 2001 From: Merwane Hamadi Date: Mon, 4 Sep 2023 14:01:45 -0700 Subject: [PATCH] Add battleship game Signed-off-by: Merwane Hamadi --- .../artifacts_out/tic_tac_toe.py | 3 +- .../5_battleship/artifacts_in/__init__.py | 0 .../artifacts_in/abstract_class.py | 107 ++++++++++++ .../5_battleship/artifacts_in/conftest.py | 62 +++++++ .../artifacts_in/product_requirements.txt | 30 ++++ .../artifacts_in/test_negative.py | 103 ++++++++++++ .../artifacts_in/test_positive.py | 149 ++++++++++++++++ .../artifacts_in/user_stories.txt | 31 ++++ .../5_battleship/artifacts_out/__init__.py | 0 .../artifacts_out/abstract_class.py | 107 ++++++++++++ .../5_battleship/artifacts_out/battleship.py | 159 ++++++++++++++++++ .../5_battleship/artifacts_out/conftest.py | 62 +++++++ .../artifacts_out/test_negative.py | 103 ++++++++++++ .../artifacts_out/test_positive.py | 149 ++++++++++++++++ .../code/5_battleship/data_draft.json | 21 +++ agbenchmark/conftest.py | 2 +- agbenchmark/utils/challenge.py | 12 ++ 17 files changed, 1098 insertions(+), 2 deletions(-) create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/__init__.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/abstract_class.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/conftest.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/product_requirements.txt create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/test_negative.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/test_positive.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/user_stories.txt create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/__init__.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/abstract_class.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/battleship.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/conftest.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/test_negative.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/test_positive.py create mode 100644 agbenchmark/challenges/verticals/code/5_battleship/data_draft.json diff --git a/agbenchmark/challenges/verticals/code/4_tic_tac_toe/artifacts_out/tic_tac_toe.py b/agbenchmark/challenges/verticals/code/4_tic_tac_toe/artifacts_out/tic_tac_toe.py index e42044a7dfe..0caa903fa44 100644 --- a/agbenchmark/challenges/verticals/code/4_tic_tac_toe/artifacts_out/tic_tac_toe.py +++ b/agbenchmark/challenges/verticals/code/4_tic_tac_toe/artifacts_out/tic_tac_toe.py @@ -75,4 +75,5 @@ def gamePlay(): if winner(board) == 0: print("Draw") -gamePlay() +if __name__ == '__main__': + gamePlay() diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/__init__.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/abstract_class.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/abstract_class.py new file mode 100644 index 00000000000..1212c07a673 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/abstract_class.py @@ -0,0 +1,107 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from pydantic import BaseModel, validator + + +# Models for the request and response payloads +class ShipPlacement(BaseModel): + ship_type: str + start: dict # {"row": int, "column": str} + direction: str + + @validator("start") + def validate_start(cls, start): + row, column = start.get("row"), start.get("column") + + if not (1 <= row <= 10): + raise ValueError("Row must be between 1 and 10 inclusive.") + + if column not in list("ABCDEFGHIJ"): + raise ValueError("Column must be one of A, B, C, D, E, F, G, H, I, J.") + + return start + + +class Turn(BaseModel): + target: dict # {"row": int, "column": str} + + +class TurnResponse(BaseModel): + result: str + ship_type: Optional[str] # This would be None if the result is a miss + + +class GameStatus(BaseModel): + is_game_over: bool + winner: Optional[str] + + +from typing import List + + +class Game(BaseModel): + game_id: str + players: List[str] + board: dict # This could represent the state of the game board, you might need to flesh this out further + ships: List[ShipPlacement] # List of ship placements for this game + turns: List[Turn] # List of turns that have been taken + + +class AbstractBattleship(ABC): + SHIP_LENGTHS = { + "carrier": 5, + "battleship": 4, + "cruiser": 3, + "submarine": 3, + "destroyer": 2, + } + + @abstractmethod + def create_ship_placement(self, game_id: str, placement: ShipPlacement) -> None: + """ + Place a ship on the grid. + """ + pass + + @abstractmethod + def create_turn(self, game_id: str, turn: Turn) -> TurnResponse: + """ + Players take turns to target a grid cell. + """ + pass + + @abstractmethod + def get_game_status(self, game_id: str) -> GameStatus: + """ + Check if the game is over and get the winner if there's one. + """ + pass + + @abstractmethod + def get_winner(self, game_id: str) -> str: + """ + Get the winner of the game. + """ + pass + + @abstractmethod + def get_game(self) -> Game: + """ + Retrieve the state of the game. + """ + pass + + @abstractmethod + def delete_game(self, game_id: str) -> None: + """ + Delete a game given its ID. + """ + pass + + @abstractmethod + def create_game(self, game_id: str) -> None: + """ + Create a new game. + """ + pass diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/conftest.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/conftest.py new file mode 100644 index 00000000000..f1e984576f7 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/conftest.py @@ -0,0 +1,62 @@ +import pytest + +from abstract_class import ShipPlacement, Turn + +from battleship import Battleship + +@pytest.fixture +def battleship_game(): + return Battleship() + + +@pytest.fixture +def initialized_game_id(battleship_game): + # Create a game instance + game_id = battleship_game.create_game() + + # Place all the ships using battleship_game's methods + sample_ship_placements = [ + ShipPlacement( + ship_type="carrier", start={"row": 1, "column": "A"}, direction="horizontal" + ), + ShipPlacement( + ship_type="battleship", + start={"row": 2, "column": "A"}, + direction="horizontal", + ), + ShipPlacement( + ship_type="cruiser", start={"row": 3, "column": "A"}, direction="horizontal" + ), + ShipPlacement( + ship_type="submarine", + start={"row": 4, "column": "A"}, + direction="horizontal", + ), + ShipPlacement( + ship_type="destroyer", + start={"row": 5, "column": "A"}, + direction="horizontal", + ), + ] + + for ship_placement in sample_ship_placements: + # Place ship using battleship_game's methods + battleship_game.create_ship_placement(game_id, ship_placement) + + return game_id + + +@pytest.fixture +def game_over_fixture(battleship_game, initialized_game_id): + # Assuming 10x10 grid, target all possible positions + for row in range(1, 11): + for column in list("ABCDEFGHIJ"): + # Player 1 takes a turn + turn = Turn(target={"row": row, "column": column}) + battleship_game.create_turn(initialized_game_id, turn) + + # Player 2 takes a turn, targeting the same position as Player 1 + battleship_game.create_turn(initialized_game_id, turn) + + # At the end of this fixture, the game should be over + return initialized_game_id diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/product_requirements.txt b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/product_requirements.txt new file mode 100644 index 00000000000..d29c1774253 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/product_requirements.txt @@ -0,0 +1,30 @@ +Specifications for Battleship + +Overview: Battleship is a two-player strategy game where each player places their fleet of ships on a grid and tries to sink the opponent's fleet by guessing their locations. +Players take turns calling out a row and column, attempting to name a square containing one of the opponent's ships. + +The Grid: Each player's grid is a 10x10 grid, identified by rows (using numbers 1-10) and columns (using letters A-J). + +Ships: + +Carrier - 5 squares +Battleship - 4 squares +Cruiser - 3 squares +Submarine - 3 squares +Destroyer - 2 squares +Each ship occupies contiguous squares on the grid, arranged either horizontally or vertically. + +Setup: + +At the start of the game, each player places their fleet on their grid. This setup is hidden from the opponent. +The game begins with Player 1, followed by Player 2, and so on. +Taking Turns: + +On a player's turn, they announce a grid square (e.g., "D5"). +The opponent announces whether that square is a "hit" (if there's a part of a ship on that square) or "miss" (if the square is empty). +If a player hits a square occupied by a ship, they get another turn to guess. This continues until they make a miss, at which point their turn ends. +If a player hits all the squares occupied by a ship, the opponent must announce the sinking of that specific ship, e.g., "You sank my Battleship!" + +Objective: The goal is to sink all of your opponent's ships before they sink yours. + +End of the Game: The game ends when one player has sunk all of the opponent's ships. The winner is the player who sinks all the opposing fleet first. diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/test_negative.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/test_negative.py new file mode 100644 index 00000000000..484ae350936 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/test_negative.py @@ -0,0 +1,103 @@ +import pytest +from pydantic import ValidationError + +from abstract_class import ShipPlacement, Turn + + +def test_ship_placement_out_of_bounds(battleship_game): + game_id = battleship_game.create_game() + + try: + out_of_bounds_ship = ShipPlacement( + ship_type="battleship", + start={"row": 11, "column": "Z"}, + direction="horizontal", + ) + except ValidationError: # Use the directly imported ValidationError class + pass + else: + with pytest.raises(ValueError, match="Placement out of bounds"): + battleship_game.create_ship_placement(game_id, out_of_bounds_ship) + + +def test_no_ship_overlap(battleship_game): + game_id = battleship_game.create_game() + placement1 = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "A"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, placement1) + placement2 = ShipPlacement( + ship_type="cruiser", start={"row": 1, "column": "A"}, direction="horizontal" + ) + with pytest.raises(ValueError): + battleship_game.create_ship_placement(game_id, placement2) + + +def test_cant_hit_before_ships_placed(battleship_game): + game_id = battleship_game.create_game() + placement1 = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "A"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, placement1) + placement2 = ShipPlacement( + ship_type="cruiser", start={"row": 4, "column": "D"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, placement2) + turn = Turn(target={"row": 1, "column": "A"}) + with pytest.raises( + ValueError, match="All ships must be placed before starting turns" + ): + battleship_game.create_turn(game_id, turn) + + +def test_cant_place_ship_after_all_ships_placed(battleship_game, initialized_game_id): + game = battleship_game.get_game( + initialized_game_id + ) + additional_ship = ShipPlacement( + ship_type="carrier", start={"row": 2, "column": "E"}, direction="horizontal" + ) + + with pytest.raises( + ValueError, match="All ships are already placed. Cannot place more ships." + ): + battleship_game.create_ship_placement(initialized_game_id, additional_ship) + + +def test_ship_placement_invalid_direction(battleship_game): + game_id = battleship_game.create_game() + + with pytest.raises(ValueError, match="Invalid ship direction"): + invalid_direction_ship = ShipPlacement( + ship_type="battleship", + start={"row": 1, "column": "A"}, + direction="diagonal", + ) + battleship_game.create_ship_placement(game_id, invalid_direction_ship) + + +def test_invalid_ship_type(battleship_game): + game_id = battleship_game.create_game() + invalid_ship = ShipPlacement( + ship_type="spacecraft", start={"row": 1, "column": "A"}, direction="horizontal" + ) + with pytest.raises(ValueError, match="Invalid ship type"): + battleship_game.create_ship_placement(game_id, invalid_ship) + + +def test_ship_placement_extends_beyond_boundaries(battleship_game): + game_id = battleship_game.create_game() + + with pytest.raises(ValueError, match="Ship extends beyond board boundaries"): + ship_extending_beyond = ShipPlacement( + ship_type="battleship", + start={"row": 1, "column": "H"}, + direction="horizontal", + ) + battleship_game.create_ship_placement(game_id, ship_extending_beyond) + + with pytest.raises(ValueError, match="Ship extends beyond board boundaries"): + ship_extending_beyond = ShipPlacement( + ship_type="cruiser", start={"row": 9, "column": "A"}, direction="vertical" + ) + battleship_game.create_ship_placement(game_id, ship_extending_beyond) diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/test_positive.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/test_positive.py new file mode 100644 index 00000000000..203b90ca942 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/test_positive.py @@ -0,0 +1,149 @@ +from abstract_class import ShipPlacement, Turn + + +def test_turns_and_results(battleship_game, initialized_game_id): + turn = Turn(target={"row": 1, "column": "A"}) + response = battleship_game.create_turn(initialized_game_id, turn) + + assert response.result in ["hit", "miss"] + if response.result == "hit": + assert response.ship_type == "carrier" + game = battleship_game.get_game(initialized_game_id) + assert turn in game.turns + + +def test_game_status_and_winner(battleship_game): + game_id = battleship_game.create_game() + status = battleship_game.get_game_status(game_id) + assert isinstance(status.is_game_over, bool) + if status.is_game_over: + winner = battleship_game.get_winner(game_id) + assert winner is not None + + +def test_delete_game(battleship_game): + game_id = battleship_game.create_game() + battleship_game.delete_game(game_id) + assert battleship_game.get_game(game_id) is None + + +def test_ship_rotation(battleship_game): + game_id = battleship_game.create_game() + placement_horizontal = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "B"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, placement_horizontal) + placement_vertical = ShipPlacement( + ship_type="cruiser", start={"row": 3, "column": "D"}, direction="vertical" + ) + battleship_game.create_ship_placement(game_id, placement_vertical) + game = battleship_game.get_game(game_id) + assert placement_horizontal in game.ships + assert placement_vertical in game.ships + + +def test_game_state_updates(battleship_game, initialized_game_id): + turn = Turn(target={"row": 3, "column": "A"}) + battleship_game.create_turn(initialized_game_id, turn) + + game = battleship_game.get_game(initialized_game_id) + + target_key = (3, ord("A") - ord("A")) + assert target_key in game.board and game.board[target_key] == "hit" + + +def test_ship_sinking_feedback(battleship_game, initialized_game_id): + hits = ["A", "B", "C", "D"] + static_moves = [ + {"row": 1, "column": "E"}, + {"row": 1, "column": "F"}, + {"row": 1, "column": "G"}, + {"row": 1, "column": "H"}, + ] + + for index, hit in enumerate(hits): + turn = Turn(target={"row": 2, "column": hit}) + response = battleship_game.create_turn(initialized_game_id, turn) + assert response.ship_type == "battleship" + + static_turn = Turn(target=static_moves[index]) + battleship_game.create_turn(initialized_game_id, static_turn) + + assert response.result == "sunk" + + +def test_restart_game(battleship_game): + game_id = battleship_game.create_game() + battleship_game.delete_game(game_id) + game_id = ( + battleship_game.create_game() + ) # Use the returned game_id after recreating the game + game = battleship_game.get_game(game_id) + assert game is not None + + +def test_ship_edge_overlapping(battleship_game): + game_id = battleship_game.create_game() + + first_ship = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "A"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, first_ship) + + next_ship = ShipPlacement( + ship_type="cruiser", start={"row": 1, "column": "E"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, next_ship) + + game = battleship_game.get_game(game_id) + assert first_ship in game.ships + assert next_ship in game.ships + + +def test_game_state_after_ship_placement(battleship_game): + game_id = battleship_game.create_game() + + ship_placement = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "A"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, ship_placement) + + game = battleship_game.get_game(game_id) + assert ship_placement in game.ships + + +def test_game_state_after_turn(initialized_game_id, battleship_game): + turn = Turn(target={"row": 1, "column": "A"}) + response = battleship_game.create_turn(initialized_game_id, turn) + + game = battleship_game.get_game(initialized_game_id) + + if response.result == "hit": + assert game.board[(1, 0)] == "hit" + else: + assert game.board[1][0] == "miss" + + +def test_multiple_hits_on_ship(battleship_game, initialized_game_id): + hit_positions = ["A", "B", "C", "D", "E"] + + for index, pos in enumerate(hit_positions): + turn = Turn(target={"row": 1, "column": pos}) + response = battleship_game.create_turn(initialized_game_id, turn) + + if index == len(hit_positions) - 1: + assert response.result == "sunk" + else: + assert response.result == "hit" + + +def test_game_over_condition(battleship_game, initialized_game_id): + for row in range(1, 11): + for column in list("ABCDEFGHIJ"): + turn = Turn(target={"row": row, "column": column}) + battleship_game.create_turn(initialized_game_id, turn) + + battleship_game.create_turn(initialized_game_id, turn) + + status = battleship_game.get_game_status(initialized_game_id) + assert status.is_game_over diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/user_stories.txt b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/user_stories.txt new file mode 100644 index 00000000000..95d1754219d --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_in/user_stories.txt @@ -0,0 +1,31 @@ +Setup and Start + +As a player, I want to start a new game so I can compete against my opponent. +As a player, I want to position my ships on a 10x10 grid so that I can set up my strategy. +As a player, I want to rotate my ships horizontally or vertically so I can choose their orientation. +As a player, I want to be ensured that ships do not overlap when placing them so that the game rules are maintained. +As a player, I want to hide my ship placements from my opponent so that my strategy remains a secret. + +Gameplay + +As a player, I want to call out a grid square during my turn so I can try to hit my opponent's ships. +As a player, when I successfully hit a ship, I want to take another turn immediately so I can capitalize on my successful guess. +As a player, when it's not my turn, I want to respond if the grid square called by my opponent is a "hit" or "miss" so that the game progresses. +As a player, I want feedback on whether my guess was a "hit" or "miss" so that I can adjust my strategy. +As a player, when my ship is completely hit, I want to inform my opponent which of my ships they have sunk, so they know their progress. +As a player, I want to keep track of my hits and misses so I can strategize my future moves. + +Endgame + +As a player, I want to be notified when all my ships have been sunk so I know I've lost. +As a player, I want to be notified when I have sunk all my opponent's ships so I know I've won. +As a player, I want to have the option to start a new game after one ends so I can play again. + +User Experience + +As a player, I want clear visuals of my grid and my opponent's grid (with hits and misses) so I can easily understand the game state. +As a player, I want audible feedback (like a splash or explosion) so that hits and misses are more engaging. +As a player, I want to be able to pause or exit the game if needed so that I can resume or quit as per my convenience. + +Not Allowed +As a player, I shouldn't be able to start hitting ships until all the ships are placed diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/__init__.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/abstract_class.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/abstract_class.py new file mode 100644 index 00000000000..1212c07a673 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/abstract_class.py @@ -0,0 +1,107 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from pydantic import BaseModel, validator + + +# Models for the request and response payloads +class ShipPlacement(BaseModel): + ship_type: str + start: dict # {"row": int, "column": str} + direction: str + + @validator("start") + def validate_start(cls, start): + row, column = start.get("row"), start.get("column") + + if not (1 <= row <= 10): + raise ValueError("Row must be between 1 and 10 inclusive.") + + if column not in list("ABCDEFGHIJ"): + raise ValueError("Column must be one of A, B, C, D, E, F, G, H, I, J.") + + return start + + +class Turn(BaseModel): + target: dict # {"row": int, "column": str} + + +class TurnResponse(BaseModel): + result: str + ship_type: Optional[str] # This would be None if the result is a miss + + +class GameStatus(BaseModel): + is_game_over: bool + winner: Optional[str] + + +from typing import List + + +class Game(BaseModel): + game_id: str + players: List[str] + board: dict # This could represent the state of the game board, you might need to flesh this out further + ships: List[ShipPlacement] # List of ship placements for this game + turns: List[Turn] # List of turns that have been taken + + +class AbstractBattleship(ABC): + SHIP_LENGTHS = { + "carrier": 5, + "battleship": 4, + "cruiser": 3, + "submarine": 3, + "destroyer": 2, + } + + @abstractmethod + def create_ship_placement(self, game_id: str, placement: ShipPlacement) -> None: + """ + Place a ship on the grid. + """ + pass + + @abstractmethod + def create_turn(self, game_id: str, turn: Turn) -> TurnResponse: + """ + Players take turns to target a grid cell. + """ + pass + + @abstractmethod + def get_game_status(self, game_id: str) -> GameStatus: + """ + Check if the game is over and get the winner if there's one. + """ + pass + + @abstractmethod + def get_winner(self, game_id: str) -> str: + """ + Get the winner of the game. + """ + pass + + @abstractmethod + def get_game(self) -> Game: + """ + Retrieve the state of the game. + """ + pass + + @abstractmethod + def delete_game(self, game_id: str) -> None: + """ + Delete a game given its ID. + """ + pass + + @abstractmethod + def create_game(self, game_id: str) -> None: + """ + Create a new game. + """ + pass diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/battleship.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/battleship.py new file mode 100644 index 00000000000..5d87181f634 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/battleship.py @@ -0,0 +1,159 @@ +from typing import Dict + +from abstract_class import ( + AbstractBattleship, + Game, + GameStatus, + ShipPlacement, + Turn, + TurnResponse, +) + + +class Battleship(AbstractBattleship): + def __init__(self): + self.games: Dict[int, Game] = {} + + def create_game(self) -> int: + game_id = str(len(self.games)) + new_game = Game( + game_id=game_id, + players=[], + board={}, + ships=[], + turns=[], + ) + + self.games[game_id] = new_game + return new_game.game_id + + def create_ship_placement(self, game_id: str, placement: ShipPlacement) -> None: + game = self.games.get(game_id) + + if not game: + raise ValueError(f"Game with ID {game_id} not found.") + if placement.direction not in ["horizontal", "vertical"]: + raise ValueError("Invalid ship direction") + if self.all_ships_placed(game): + raise ValueError("All ships are already placed. Cannot place more ships.") + + ship_length = self.SHIP_LENGTHS.get(placement.ship_type) + if not ship_length: + raise ValueError(f"Invalid ship type {placement.ship_type}") + + start_row, start_col = placement.start["row"], ord( + placement.start["column"] + ) - ord("A") + + if start_row < 1 or start_row > 10 or start_col < 0 or start_col > 9: + raise ValueError("Placement out of bounds") + + if placement.direction == "horizontal" and start_col + ship_length > 10: + raise ValueError("Ship extends beyond board boundaries") + elif placement.direction == "vertical" and start_row + ship_length > 10: + raise ValueError("Ship extends beyond board boundaries") + + for i in range(ship_length): + if placement.direction == "horizontal": + if game.board.get((start_row, start_col + i)): + raise ValueError("Ship overlaps with another ship!") + elif placement.direction == "vertical": + if game.board.get((start_row + i, start_col)): + raise ValueError("Ship overlaps with another ship!") + + for i in range(ship_length): + if placement.direction == "horizontal": + game.board[(start_row, start_col + i)] = placement.ship_type + else: + game.board[(start_row + i, start_col)] = placement.ship_type + + game.ships.append(placement) + + def create_turn(self, game_id: str, turn: Turn) -> TurnResponse: + game = self.games.get(game_id) + + if not game: + raise ValueError(f"Game with ID {game_id} not found.") + + if not self.all_ships_placed(game): + raise ValueError("All ships must be placed before starting turns") + + target_row, target_col = turn.target["row"], ord(turn.target["column"]) - ord( + "A" + ) + hit_ship = game.board.get((target_row, target_col)) + + game.turns.append(turn) + + if hit_ship == "hit": + return TurnResponse( + result="miss", ship_type=None + ) + + if hit_ship: + ship_placement = next(sp for sp in game.ships if sp.ship_type == hit_ship) + + if hit_ship: + ship_placement = next(sp for sp in game.ships if sp.ship_type == hit_ship) + start_row, start_col = ship_placement.start["row"], ord( + ship_placement.start["column"] + ) - ord("A") + ship_positions = [ + ( + start_row + (i if ship_placement.direction == "vertical" else 0), + start_col + (i if ship_placement.direction == "horizontal" else 0), + ) + for i in range(self.SHIP_LENGTHS[hit_ship]) + ] + + targeted_positions = { + (t.target["row"], ord(t.target["column"]) - ord("A")) + for t in game.turns + } + + game.board[(target_row, target_col)] = "hit" + + if set(ship_positions).issubset(targeted_positions): + for pos in ship_positions: + game.board[pos] = "hit" + return TurnResponse(result="sunk", ship_type=hit_ship) + else: + return TurnResponse(result="hit", ship_type=hit_ship) + + def get_game_status(self, game_id: str) -> GameStatus: + game = self.games.get(game_id) + + if not game: + raise ValueError(f"Game with ID {game_id} not found.") + + hits = sum(1 for _, status in game.board.items() if status == "hit") + + total_ships_length = sum( + self.SHIP_LENGTHS[ship.ship_type] for ship in game.ships + ) + + if hits == total_ships_length: + return GameStatus( + is_game_over=True, winner="player" + ) + else: + return GameStatus(is_game_over=False, winner=None) + + def get_winner(self, game_id: str) -> str: + game_status = self.get_game_status(game_id) + + if game_status.is_game_over: + return game_status.winner + else: + return None + + def get_game(self, game_id: str) -> Game: + return self.games.get(game_id) + + def delete_game(self, game_id: str) -> None: + if game_id in self.games: + del self.games[game_id] + + def all_ships_placed(self, game: Game) -> bool: + placed_ship_types = set([placement.ship_type for placement in game.ships]) + return placed_ship_types == set(self.SHIP_LENGTHS.keys()) diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/conftest.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/conftest.py new file mode 100644 index 00000000000..f1e984576f7 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/conftest.py @@ -0,0 +1,62 @@ +import pytest + +from abstract_class import ShipPlacement, Turn + +from battleship import Battleship + +@pytest.fixture +def battleship_game(): + return Battleship() + + +@pytest.fixture +def initialized_game_id(battleship_game): + # Create a game instance + game_id = battleship_game.create_game() + + # Place all the ships using battleship_game's methods + sample_ship_placements = [ + ShipPlacement( + ship_type="carrier", start={"row": 1, "column": "A"}, direction="horizontal" + ), + ShipPlacement( + ship_type="battleship", + start={"row": 2, "column": "A"}, + direction="horizontal", + ), + ShipPlacement( + ship_type="cruiser", start={"row": 3, "column": "A"}, direction="horizontal" + ), + ShipPlacement( + ship_type="submarine", + start={"row": 4, "column": "A"}, + direction="horizontal", + ), + ShipPlacement( + ship_type="destroyer", + start={"row": 5, "column": "A"}, + direction="horizontal", + ), + ] + + for ship_placement in sample_ship_placements: + # Place ship using battleship_game's methods + battleship_game.create_ship_placement(game_id, ship_placement) + + return game_id + + +@pytest.fixture +def game_over_fixture(battleship_game, initialized_game_id): + # Assuming 10x10 grid, target all possible positions + for row in range(1, 11): + for column in list("ABCDEFGHIJ"): + # Player 1 takes a turn + turn = Turn(target={"row": row, "column": column}) + battleship_game.create_turn(initialized_game_id, turn) + + # Player 2 takes a turn, targeting the same position as Player 1 + battleship_game.create_turn(initialized_game_id, turn) + + # At the end of this fixture, the game should be over + return initialized_game_id diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/test_negative.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/test_negative.py new file mode 100644 index 00000000000..484ae350936 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/test_negative.py @@ -0,0 +1,103 @@ +import pytest +from pydantic import ValidationError + +from abstract_class import ShipPlacement, Turn + + +def test_ship_placement_out_of_bounds(battleship_game): + game_id = battleship_game.create_game() + + try: + out_of_bounds_ship = ShipPlacement( + ship_type="battleship", + start={"row": 11, "column": "Z"}, + direction="horizontal", + ) + except ValidationError: # Use the directly imported ValidationError class + pass + else: + with pytest.raises(ValueError, match="Placement out of bounds"): + battleship_game.create_ship_placement(game_id, out_of_bounds_ship) + + +def test_no_ship_overlap(battleship_game): + game_id = battleship_game.create_game() + placement1 = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "A"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, placement1) + placement2 = ShipPlacement( + ship_type="cruiser", start={"row": 1, "column": "A"}, direction="horizontal" + ) + with pytest.raises(ValueError): + battleship_game.create_ship_placement(game_id, placement2) + + +def test_cant_hit_before_ships_placed(battleship_game): + game_id = battleship_game.create_game() + placement1 = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "A"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, placement1) + placement2 = ShipPlacement( + ship_type="cruiser", start={"row": 4, "column": "D"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, placement2) + turn = Turn(target={"row": 1, "column": "A"}) + with pytest.raises( + ValueError, match="All ships must be placed before starting turns" + ): + battleship_game.create_turn(game_id, turn) + + +def test_cant_place_ship_after_all_ships_placed(battleship_game, initialized_game_id): + game = battleship_game.get_game( + initialized_game_id + ) + additional_ship = ShipPlacement( + ship_type="carrier", start={"row": 2, "column": "E"}, direction="horizontal" + ) + + with pytest.raises( + ValueError, match="All ships are already placed. Cannot place more ships." + ): + battleship_game.create_ship_placement(initialized_game_id, additional_ship) + + +def test_ship_placement_invalid_direction(battleship_game): + game_id = battleship_game.create_game() + + with pytest.raises(ValueError, match="Invalid ship direction"): + invalid_direction_ship = ShipPlacement( + ship_type="battleship", + start={"row": 1, "column": "A"}, + direction="diagonal", + ) + battleship_game.create_ship_placement(game_id, invalid_direction_ship) + + +def test_invalid_ship_type(battleship_game): + game_id = battleship_game.create_game() + invalid_ship = ShipPlacement( + ship_type="spacecraft", start={"row": 1, "column": "A"}, direction="horizontal" + ) + with pytest.raises(ValueError, match="Invalid ship type"): + battleship_game.create_ship_placement(game_id, invalid_ship) + + +def test_ship_placement_extends_beyond_boundaries(battleship_game): + game_id = battleship_game.create_game() + + with pytest.raises(ValueError, match="Ship extends beyond board boundaries"): + ship_extending_beyond = ShipPlacement( + ship_type="battleship", + start={"row": 1, "column": "H"}, + direction="horizontal", + ) + battleship_game.create_ship_placement(game_id, ship_extending_beyond) + + with pytest.raises(ValueError, match="Ship extends beyond board boundaries"): + ship_extending_beyond = ShipPlacement( + ship_type="cruiser", start={"row": 9, "column": "A"}, direction="vertical" + ) + battleship_game.create_ship_placement(game_id, ship_extending_beyond) diff --git a/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/test_positive.py b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/test_positive.py new file mode 100644 index 00000000000..203b90ca942 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/artifacts_out/test_positive.py @@ -0,0 +1,149 @@ +from abstract_class import ShipPlacement, Turn + + +def test_turns_and_results(battleship_game, initialized_game_id): + turn = Turn(target={"row": 1, "column": "A"}) + response = battleship_game.create_turn(initialized_game_id, turn) + + assert response.result in ["hit", "miss"] + if response.result == "hit": + assert response.ship_type == "carrier" + game = battleship_game.get_game(initialized_game_id) + assert turn in game.turns + + +def test_game_status_and_winner(battleship_game): + game_id = battleship_game.create_game() + status = battleship_game.get_game_status(game_id) + assert isinstance(status.is_game_over, bool) + if status.is_game_over: + winner = battleship_game.get_winner(game_id) + assert winner is not None + + +def test_delete_game(battleship_game): + game_id = battleship_game.create_game() + battleship_game.delete_game(game_id) + assert battleship_game.get_game(game_id) is None + + +def test_ship_rotation(battleship_game): + game_id = battleship_game.create_game() + placement_horizontal = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "B"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, placement_horizontal) + placement_vertical = ShipPlacement( + ship_type="cruiser", start={"row": 3, "column": "D"}, direction="vertical" + ) + battleship_game.create_ship_placement(game_id, placement_vertical) + game = battleship_game.get_game(game_id) + assert placement_horizontal in game.ships + assert placement_vertical in game.ships + + +def test_game_state_updates(battleship_game, initialized_game_id): + turn = Turn(target={"row": 3, "column": "A"}) + battleship_game.create_turn(initialized_game_id, turn) + + game = battleship_game.get_game(initialized_game_id) + + target_key = (3, ord("A") - ord("A")) + assert target_key in game.board and game.board[target_key] == "hit" + + +def test_ship_sinking_feedback(battleship_game, initialized_game_id): + hits = ["A", "B", "C", "D"] + static_moves = [ + {"row": 1, "column": "E"}, + {"row": 1, "column": "F"}, + {"row": 1, "column": "G"}, + {"row": 1, "column": "H"}, + ] + + for index, hit in enumerate(hits): + turn = Turn(target={"row": 2, "column": hit}) + response = battleship_game.create_turn(initialized_game_id, turn) + assert response.ship_type == "battleship" + + static_turn = Turn(target=static_moves[index]) + battleship_game.create_turn(initialized_game_id, static_turn) + + assert response.result == "sunk" + + +def test_restart_game(battleship_game): + game_id = battleship_game.create_game() + battleship_game.delete_game(game_id) + game_id = ( + battleship_game.create_game() + ) # Use the returned game_id after recreating the game + game = battleship_game.get_game(game_id) + assert game is not None + + +def test_ship_edge_overlapping(battleship_game): + game_id = battleship_game.create_game() + + first_ship = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "A"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, first_ship) + + next_ship = ShipPlacement( + ship_type="cruiser", start={"row": 1, "column": "E"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, next_ship) + + game = battleship_game.get_game(game_id) + assert first_ship in game.ships + assert next_ship in game.ships + + +def test_game_state_after_ship_placement(battleship_game): + game_id = battleship_game.create_game() + + ship_placement = ShipPlacement( + ship_type="battleship", start={"row": 1, "column": "A"}, direction="horizontal" + ) + battleship_game.create_ship_placement(game_id, ship_placement) + + game = battleship_game.get_game(game_id) + assert ship_placement in game.ships + + +def test_game_state_after_turn(initialized_game_id, battleship_game): + turn = Turn(target={"row": 1, "column": "A"}) + response = battleship_game.create_turn(initialized_game_id, turn) + + game = battleship_game.get_game(initialized_game_id) + + if response.result == "hit": + assert game.board[(1, 0)] == "hit" + else: + assert game.board[1][0] == "miss" + + +def test_multiple_hits_on_ship(battleship_game, initialized_game_id): + hit_positions = ["A", "B", "C", "D", "E"] + + for index, pos in enumerate(hit_positions): + turn = Turn(target={"row": 1, "column": pos}) + response = battleship_game.create_turn(initialized_game_id, turn) + + if index == len(hit_positions) - 1: + assert response.result == "sunk" + else: + assert response.result == "hit" + + +def test_game_over_condition(battleship_game, initialized_game_id): + for row in range(1, 11): + for column in list("ABCDEFGHIJ"): + turn = Turn(target={"row": row, "column": column}) + battleship_game.create_turn(initialized_game_id, turn) + + battleship_game.create_turn(initialized_game_id, turn) + + status = battleship_game.get_game_status(initialized_game_id) + assert status.is_game_over diff --git a/agbenchmark/challenges/verticals/code/5_battleship/data_draft.json b/agbenchmark/challenges/verticals/code/5_battleship/data_draft.json new file mode 100644 index 00000000000..a4075f9cc19 --- /dev/null +++ b/agbenchmark/challenges/verticals/code/5_battleship/data_draft.json @@ -0,0 +1,21 @@ +{ + "name": "TestBattleship", + "category": ["code"], + "task": "Build a battleship game\n\nSpecifications:\n\nOverview: Battleship is a two-player strategy game where each player places their fleet of ships on a grid and tries to sink the opponent's fleet by guessing their locations.\nPlayers take turns calling out a row and column, attempting to name a square containing one of the opponent's ships.\n\nThe Grid: Each player's grid is a 10x10 grid, identified by rows (using numbers 1-10) and columns (using letters A-J).\n\nShips:\n\nCarrier - 5 squares\nBattleship - 4 squares\nCruiser - 3 squares\nSubmarine - 3 squares\nDestroyer - 2 squares\nEach ship occupies contiguous squares on the grid, arranged either horizontally or vertically.\n\nSetup:\n\nAt the start of the game, each player places their fleet on their grid. This setup is hidden from the opponent.\nThe game begins with Player 1, followed by Player 2, and so on.\nTaking Turns:\n\nOn a player's turn, they announce a grid square (e.g., \"D5\").\nThe opponent announces whether that square is a \"hit\" (if there's a part of a ship on that square) or \"miss\" (if the square is empty).\nIf a player hits a square occupied by a ship, they get another turn to guess. This continues until they make a miss, at which point their turn ends.\nIf a player hits all the squares occupied by a ship, the opponent must announce the sinking of that specific ship, e.g., \"You sank my Battleship!\"\n\nObjective: The goal is to sink all of your opponent's ships before they sink yours.\n\nEnd of the Game: The game ends when one player has sunk all of the opponent's ships. The winner is the player who sinks all the opposing fleet first.\n\nTechnical details:\nIn your root folder you will find an abstract class that defines the public interface of the Battleship class you will have to build:\n```\nfrom abc import ABC, abstractmethod\nfrom typing import Optional\n\nfrom pydantic import BaseModel, validator\n\n\n# Models for the request and response payloads\nclass ShipPlacement(BaseModel):\n ship_type: str\n start: dict # {\"row\": int, \"column\": str}\n direction: str\n\n @validator(\"start\")\n def validate_start(cls, start):\n row, column = start.get(\"row\"), start.get(\"column\")\n\n if not (1 <= row <= 10):\n raise ValueError(\"Row must be between 1 and 10 inclusive.\")\n\n if column not in list(\"ABCDEFGHIJ\"):\n raise ValueError(\"Column must be one of A, B, C, D, E, F, G, H, I, J.\")\n\n return start\n\n\nclass Turn(BaseModel):\n target: dict # {\"row\": int, \"column\": str}\n\n\nclass TurnResponse(BaseModel):\n result: str\n ship_type: Optional[str] # This would be None if the result is a miss\n\n\nclass GameStatus(BaseModel):\n is_game_over: bool\n winner: Optional[str]\n\n\nfrom typing import List\n\n\nclass Game(BaseModel):\n game_id: str\n players: List[str]\n board: dict # This could represent the state of the game board, you might need to flesh this out further\n ships: List[ShipPlacement] # List of ship placements for this game\n turns: List[Turn] # List of turns that have been taken\n\n\nclass AbstractBattleship(ABC):\n SHIP_LENGTHS = {\n \"carrier\": 5,\n \"battleship\": 4,\n \"cruiser\": 3,\n \"submarine\": 3,\n \"destroyer\": 2,\n }\n\n @abstractmethod\n def create_ship_placement(self, game_id: str, placement: ShipPlacement) -> None:\n \"\"\"\n Place a ship on the grid.\n \"\"\"\n pass\n\n @abstractmethod\n def create_turn(self, game_id: str, turn: Turn) -> TurnResponse:\n \"\"\"\n Players take turns to target a grid cell.\n \"\"\"\n pass\n\n @abstractmethod\n def get_game_status(self, game_id: str) -> GameStatus:\n \"\"\"\n Check if the game is over and get the winner if there's one.\n \"\"\"\n pass\n\n @abstractmethod\n def get_winner(self, game_id: str) -> str:\n \"\"\"\n Get the winner of the game.\n \"\"\"\n pass\n\n @abstractmethod\n def get_game(self) -> Game:\n \"\"\"\n Retrieve the state of the game.\n \"\"\"\n pass\n\n @abstractmethod\n def delete_game(self, game_id: str) -> None:\n \"\"\"\n Delete a game given its ID.\n \"\"\"\n pass\n\n @abstractmethod\n def create_game(self, game_id: str) -> None:\n \"\"\"\n Create a new game.\n \"\"\"\n pass\n\n```\nAt any moment you can run ```pytest``` to execute the tests.\nYou have two types of test: \n- positive tests => test the battleship game being used in ideal conditions\n- negative tests => tests the battleship game behaviour when used incorrectly\n\nSuccess criteria:\n- you will need to write a file called battleship.py that implements the abstract Battleship class.\n- this class will have to pass all the tests.\n- you're not allowed to modify any other file than the battleship.py. You can add other files as long as the main entrypoint is the battleship class.", + "dependencies": [], + "cutoff": 90, + "ground": { + "answer": "The implementation of battleship that passes all the tests.", + "should_contain": [], + "should_not_contain": [], + "files": [], + "eval": { + "type": "pytest" + } + }, + "info": { + "difficulty": "expert", + "description": "Tests ability for the agent to create a Battleship.", + "side_effects": [] + } +} diff --git a/agbenchmark/conftest.py b/agbenchmark/conftest.py index 686f2fc4573..8afd3729fe9 100644 --- a/agbenchmark/conftest.py +++ b/agbenchmark/conftest.py @@ -24,7 +24,7 @@ ) pytest_plugins = ["agbenchmark.utils.dependencies"] - +collect_ignore = ["challenges"] def resolve_workspace(workspace: str) -> str: if workspace.startswith("${") and workspace.endswith("}"): diff --git a/agbenchmark/utils/challenge.py b/agbenchmark/utils/challenge.py index a07898e958d..72849f51052 100644 --- a/agbenchmark/utils/challenge.py +++ b/agbenchmark/utils/challenge.py @@ -126,6 +126,18 @@ def get_artifacts_out( else: with open(file_path, "r") as f: files_contents.append(f.read()) + else: + if ground.eval.type == "pytest": + result = subprocess.run( + [sys.executable, "-m", "pytest"], + cwd=os.path.abspath(workspace), + capture_output=True, + text=True, + ) + if "error" in result.stderr or result.returncode != 0: + print(result.stderr) + assert False, result.stderr + files_contents.append(f"Output: {result.stdout}\n") return files_contents