From 11f970ad628afe5af23e458b0c96d846994e4d59 Mon Sep 17 00:00:00 2001 From: Roman Vlasenko Date: Sun, 15 Sep 2024 01:50:26 +0300 Subject: [PATCH 1/9] Make Coordinate hashable --- battleship/engine/domain.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/battleship/engine/domain.py b/battleship/engine/domain.py index 5f8a310..d8824b0 100644 --- a/battleship/engine/domain.py +++ b/battleship/engine/domain.py @@ -58,6 +58,9 @@ def __eq__(self, other: object) -> bool: raise NotImplementedError(f"Cannot compare Coordinate to {other.__class__.__name__}.") + def __hash__(self) -> int: + return hash((self.x, self.y)) + def up(self) -> "Coordinate": return Coordinate(self.x, self.y - 1) From ae0834d5380802a3a7c865a861b96c4fa5d396ce Mon Sep 17 00:00:00 2001 From: Roman Vlasenko Date: Sun, 15 Sep 2024 23:31:59 +0300 Subject: [PATCH 2/9] Make targeting algorithm account for 'No adjacent ships' option --- battleship/engine/ai.py | 61 +++++++++++++++++++++++++-------- battleship/engine/domain.py | 65 ++++++++++++++++++++++++------------ battleship/tui/strategies.py | 4 ++- 3 files changed, 94 insertions(+), 36 deletions(-) diff --git a/battleship/engine/ai.py b/battleship/engine/ai.py index 4836a85..0d3344d 100644 --- a/battleship/engine/ai.py +++ b/battleship/engine/ai.py @@ -6,9 +6,11 @@ class TargetCaller: - def __init__(self, board: domain.Board) -> None: + def __init__(self, board: domain.Board, no_adjacent_ships: bool = False) -> None: self.board = board + self.no_adjacent_ships = no_adjacent_ships self.next_targets: deque[domain.Cell] = deque() + self.excluded_cells: set[domain.Coordinate] = set() def call_out(self, *, count: int = 1) -> list[str]: cells = self._get_targets(count) @@ -16,14 +18,21 @@ def call_out(self, *, count: int = 1) -> list[str]: def provide_feedback(self, shots: Iterable[domain.Shot]) -> None: for shot in shots: - if shot.hit and not shot.ship.destroyed: # type: ignore - cell = self.board.get_cell(shot.coordinate) + if shot.hit: + assert shot.ship, "Shot was a hit, but no ship present" + + if shot.ship.destroyed and self.no_adjacent_ships: + coordinates = self._find_cells_around_ship(shot.ship) + self.excluded_cells.update(coordinates) + self.next_targets.clear() + elif not shot.ship.destroyed: + cell = self.board.get_cell(shot.coordinate) - if cell is None: - raise errors.CellOutOfRange(f"Cell at {shot.coordinate} doesn't exist.") + if cell is None: + raise errors.CellOutOfRange(f"Cell at {shot.coordinate} doesn't exist.") - neighbors = self._find_neighbor_cells(cell) - self.next_targets.extend(neighbors) + cells = self._find_adjacent_cells(cell) + self.next_targets.extend(cells) def _get_targets(self, count: int) -> list[domain.Cell]: targets: list[domain.Cell] = [] @@ -39,19 +48,43 @@ def _get_targets(self, count: int) -> list[domain.Cell]: return targets def _find_random_targets(self, count: int) -> list[domain.Cell]: - candidates = [cell for cell in self.board.cells if not cell.is_shot] + candidates = [ + cell + for cell in self.board.cells + if not (cell.is_shot or cell.coordinate in self.excluded_cells) + ] return random.sample(candidates, k=min(len(candidates), count)) - def _find_neighbor_cells(self, cell: domain.Cell) -> list[domain.Cell]: + def _find_adjacent_cells(self, cell: domain.Cell) -> list[domain.Cell]: cells = [] - for direction in list(domain.Direction): - candidate = self.board.get_adjacent_cell(cell, direction) # type: ignore[arg-type] - - if candidate is None or candidate.is_shot or candidate in self.next_targets: + for cell_ in self.board.get_adjacent_cells(cell, with_diagonals=False): + if ( + cell_.is_shot + or cell_ in self.next_targets + or cell_.coordinate in self.excluded_cells + ): continue - cells.append(candidate) + cells.append(cell_) + + return cells + + def _find_cells_around_ship(self, ship: domain.Ship) -> list[domain.Coordinate]: + cells = [] + + for coordinate in ship.cells: + cell = self.board.get_cell(coordinate) + assert cell, "Ship was placed on out-of-range cell" + + adjacent_cells = self.board.get_adjacent_cells(cell) + adjacent_coordinates = [ + cell.coordinate + for cell in adjacent_cells + if not cell.is_shot and cell.coordinate not in ship.cells + ] + + cells.extend(adjacent_coordinates) return cells diff --git a/battleship/engine/domain.py b/battleship/engine/domain.py index d8824b0..cba842d 100644 --- a/battleship/engine/domain.py +++ b/battleship/engine/domain.py @@ -17,10 +17,17 @@ class Direction(StrEnum): - UP = "up" - DOWN = "down" - RIGHT = "right" - LEFT = "left" + UP = enum.auto() + DOWN = enum.auto() + RIGHT = enum.auto() + LEFT = enum.auto() + + +class DiagonalDirection(StrEnum): + UP_RIGHT = enum.auto() + UP_LEFT = enum.auto() + DOWN_RIGHT = enum.auto() + DOWN_LEFT = enum.auto() class FiringOrder(StrEnum): @@ -167,7 +174,9 @@ def __repr__(self) -> str: def cells(self) -> list[Cell]: return [cell for row in self.grid for cell in row] - def get_adjacent_cell(self, cell: Cell, direction: Direction) -> Cell | None: + def get_adjacent_cell( + self, cell: Cell, direction: Direction | DiagonalDirection + ) -> Cell | None: match direction: case Direction.UP: coordinate = cell.coordinate.up() @@ -177,11 +186,39 @@ def get_adjacent_cell(self, cell: Cell, direction: Direction) -> Cell | None: coordinate = cell.coordinate.right() case Direction.LEFT: coordinate = cell.coordinate.left() + case DiagonalDirection.UP_RIGHT: + up = cell.coordinate.up() + coordinate = up.right() + case DiagonalDirection.UP_LEFT: + up = cell.coordinate.up() + coordinate = up.left() + case DiagonalDirection.DOWN_RIGHT: + down = cell.coordinate.down() + coordinate = down.right() + case DiagonalDirection.DOWN_LEFT: + down = cell.coordinate.down() + coordinate = down.left() case _: raise ValueError(f"Invalid direction {direction}.") return self.get_cell(coordinate) + def get_adjacent_cells(self, cell: Cell, with_diagonals: bool = True) -> list[Cell]: + cells = [] + + if with_diagonals: + directions = itertools.chain(Direction, DiagonalDirection) + else: + directions = itertools.chain(Direction) + + for direction in directions: + adjacent_cell = self.get_adjacent_cell(cell, direction) # type: ignore[arg-type] + + if adjacent_cell is not None: + cells.append(adjacent_cell) + + return cells + def get_cell(self, coordinate: Coordinate) -> Cell | None: if not (0 <= coordinate.x < self.size and 0 <= coordinate.y < self.size): return None @@ -194,23 +231,9 @@ def has_adjacent_ship(self, coordinate: Coordinate) -> bool: if not cell: raise errors.CellOutOfRange(f"Cell at {coordinate=} does not exist.") - adjacent_coordinates = [ - cell.coordinate.up(), - cell.coordinate.right(), - cell.coordinate.down(), - cell.coordinate.left(), - ] - diagonals = [ - adjacent_coordinates[1].up(), - adjacent_coordinates[1].down(), - adjacent_coordinates[3].up(), - adjacent_coordinates[3].down(), - ] - adjacent_coordinates.extend(diagonals) - - cells = [self.get_cell(coor) for coor in adjacent_coordinates] + adjacent_cells = self.get_adjacent_cells(cell) - return any([cell is not None and cell.ship is not None for cell in cells]) + return any([cell.ship is not None for cell in adjacent_cells]) def place_ship( self, coordinates: Collection[Coordinate], ship: Ship, no_adjacent_ships: bool = False diff --git a/battleship/tui/strategies.py b/battleship/tui/strategies.py index befe636..7414148 100644 --- a/battleship/tui/strategies.py +++ b/battleship/tui/strategies.py @@ -238,7 +238,9 @@ def __init__(self, game: domain.Game): self._enable_move_delay = not is_debug() self._human_player = game.player_a self._bot_player = game.player_b - self._target_caller = ai.TargetCaller(self._human_player.board) + self._target_caller = ai.TargetCaller( + self._human_player.board, self._game.no_adjacent_ships + ) self._autoplacer = ai.Autoplacer( self._bot_player.board, self._game.roster, self._game.no_adjacent_ships ) From 9c7d7b37ef342ce3520de484effe44ecd1b4a024 Mon Sep 17 00:00:00 2001 From: Roman Vlasenko Date: Mon, 16 Sep 2024 00:02:11 +0300 Subject: [PATCH 3/9] Add tests --- tests/test_ai.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_ai.py b/tests/test_ai.py index 123779f..d5bdc13 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -49,6 +49,47 @@ def test_target_caller_targets_adjacent_cells_after_hit_until_all_tried(): assert caller.call_out(count=4) == ["B4", "C3", "A3", "C9"] +@pytest.mark.parametrize( + "ship_position, excluded_cells", + [ + ["B1", ["A2", "C2", "A1", "B2", "C1"]], # Ship positioned at the top edge. + ["B2", ["A2", "B3", "C2", "A1", "C1", "A3", "C3", "B1"]], + ], +) +def test_target_caller_excludes_adjacent_cells_if_adjacent_ships_disallowed( + ship_position, excluded_cells +): + board = domain.Board() + ship = domain.Ship("id", "ship", 1) + board.place_ship(domain.position_to_coordinates([ship_position]), ship) + board.hit_cell(domain.Coordinate.from_human(ship_position)) + shot = domain.Shot(domain.Coordinate.from_human(ship_position), hit=True, ship=ship) + caller = ai.TargetCaller(board, no_adjacent_ships=True) + + caller.provide_feedback([shot]) + + assert [c.to_human() for c in caller.excluded_cells] == excluded_cells + + +def test_target_caller_clears_next_targets_if_ship_destroyed_and_adjacent_ships_disallowed(): + board = domain.Board() + caller = ai.TargetCaller(board, no_adjacent_ships=True) + ship = domain.Ship("id", "ship", 2) + board.place_ship(domain.position_to_coordinates(["B2", "B3"]), ship) + + board.hit_cell(domain.Coordinate.from_human("B2")) + shot = domain.Shot(domain.Coordinate.from_human("B2"), hit=True, ship=ship) + caller.provide_feedback([shot]) + + assert len(caller.next_targets) == 4 + + board.hit_cell(domain.Coordinate.from_human("B3")) + next_shot = domain.Shot(domain.Coordinate.from_human("B3"), hit=True, ship=ship) + caller.provide_feedback([next_shot]) + + assert not len(caller.next_targets) + + @pytest.mark.parametrize("ship", [*rosters.get_roster("classic")]) def test_autoplacer_position_matches_ship_hp(ship): board = domain.Board() From 7d36ff3f5026ec9856c3e78a235160f325ce3f73 Mon Sep 17 00:00:00 2001 From: Roman Vlasenko Date: Mon, 16 Sep 2024 00:07:34 +0300 Subject: [PATCH 4/9] Refactor AI tests --- tests/test_ai.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_ai.py b/tests/test_ai.py index d5bdc13..b71b6e8 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -52,7 +52,7 @@ def test_target_caller_targets_adjacent_cells_after_hit_until_all_tried(): @pytest.mark.parametrize( "ship_position, excluded_cells", [ - ["B1", ["A2", "C2", "A1", "B2", "C1"]], # Ship positioned at the top edge. + ["B1", ["A2", "C2", "A1", "B2", "C1"]], # Ship is positioned at the top edge. ["B2", ["A2", "B3", "C2", "A1", "C1", "A3", "C3", "B1"]], ], ) @@ -60,12 +60,12 @@ def test_target_caller_excludes_adjacent_cells_if_adjacent_ships_disallowed( ship_position, excluded_cells ): board = domain.Board() + caller = ai.TargetCaller(board, no_adjacent_ships=True) ship = domain.Ship("id", "ship", 1) board.place_ship(domain.position_to_coordinates([ship_position]), ship) + board.hit_cell(domain.Coordinate.from_human(ship_position)) shot = domain.Shot(domain.Coordinate.from_human(ship_position), hit=True, ship=ship) - caller = ai.TargetCaller(board, no_adjacent_ships=True) - caller.provide_feedback([shot]) assert [c.to_human() for c in caller.excluded_cells] == excluded_cells @@ -131,7 +131,7 @@ def test_autoplacer_raises_error_if_no_place_for_ship(): autoplacer.place("carrier") -def test_autoplacer_respects_no_ships_touch_rule(): +def test_autoplacer_respects_no_adjacent_ships_rule(): board = domain.Board(size=3) ship = domain.Ship(id="1", hp=3, type="ship") roster = rosters.Roster(name="test", items=[rosters.RosterItem(id="1", type="ship", hp=3)]) From c74c0c4d1aed88c1da21173e0b09728652d5f2a4 Mon Sep 17 00:00:00 2001 From: Roman Vlasenko Date: Tue, 17 Sep 2024 23:48:12 +0300 Subject: [PATCH 5/9] Refactor domain model to use Coordinate as Board methods' input instead of Cell --- battleship/engine/ai.py | 46 +++++++++---------- battleship/engine/domain.py | 87 +++++++++++++++++------------------- battleship/tui/strategies.py | 6 +-- tests/domain/test_board.py | 49 ++++++-------------- tests/test_ai.py | 4 +- 5 files changed, 80 insertions(+), 112 deletions(-) diff --git a/battleship/engine/ai.py b/battleship/engine/ai.py index 0d3344d..d503bd2 100644 --- a/battleship/engine/ai.py +++ b/battleship/engine/ai.py @@ -9,12 +9,12 @@ class TargetCaller: def __init__(self, board: domain.Board, no_adjacent_ships: bool = False) -> None: self.board = board self.no_adjacent_ships = no_adjacent_ships - self.next_targets: deque[domain.Cell] = deque() + self.next_targets: deque[domain.Coordinate] = deque() self.excluded_cells: set[domain.Coordinate] = set() def call_out(self, *, count: int = 1) -> list[str]: - cells = self._get_targets(count) - return [cell.coordinate.to_human() for cell in cells] + targets = self._get_targets(count) + return [target.to_human() for target in targets] def provide_feedback(self, shots: Iterable[domain.Shot]) -> None: for shot in shots: @@ -26,16 +26,11 @@ def provide_feedback(self, shots: Iterable[domain.Shot]) -> None: self.excluded_cells.update(coordinates) self.next_targets.clear() elif not shot.ship.destroyed: - cell = self.board.get_cell(shot.coordinate) - - if cell is None: - raise errors.CellOutOfRange(f"Cell at {shot.coordinate} doesn't exist.") - - cells = self._find_adjacent_cells(cell) + cells = self._find_adjacent_cells(shot.coordinate) self.next_targets.extend(cells) - def _get_targets(self, count: int) -> list[domain.Cell]: - targets: list[domain.Cell] = [] + def _get_targets(self, count: int) -> list[domain.Coordinate]: + targets: list[domain.Coordinate] = [] while len(self.next_targets) and len(targets) != count: next_target = self.next_targets.popleft() @@ -47,26 +42,26 @@ def _get_targets(self, count: int) -> list[domain.Cell]: return targets - def _find_random_targets(self, count: int) -> list[domain.Cell]: + def _find_random_targets(self, count: int) -> list[domain.Coordinate]: candidates = [ - cell + cell.coordinate for cell in self.board.cells if not (cell.is_shot or cell.coordinate in self.excluded_cells) ] return random.sample(candidates, k=min(len(candidates), count)) - def _find_adjacent_cells(self, cell: domain.Cell) -> list[domain.Cell]: + def _find_adjacent_cells(self, coordinate: domain.Coordinate) -> list[domain.Coordinate]: cells = [] - for cell_ in self.board.get_adjacent_cells(cell, with_diagonals=False): + for cell_ in self.board.get_adjacent_cells(coordinate, with_diagonals=False): if ( cell_.is_shot - or cell_ in self.next_targets + or cell_.coordinate in self.next_targets or cell_.coordinate in self.excluded_cells ): continue - cells.append(cell_) + cells.append(cell_.coordinate) return cells @@ -74,10 +69,7 @@ def _find_cells_around_ship(self, ship: domain.Ship) -> list[domain.Coordinate]: cells = [] for coordinate in ship.cells: - cell = self.board.get_cell(coordinate) - assert cell, "Ship was placed on out-of-range cell" - - adjacent_cells = self.board.get_adjacent_cells(cell) + adjacent_cells = self.board.get_adjacent_cells(coordinate) adjacent_coordinates = [ cell.coordinate for cell in adjacent_cells @@ -100,7 +92,11 @@ def __init__(self, board: domain.Board, ship_suite: rosters.Roster, no_adjacent_ def place(self, ship_type: rosters.ShipType) -> list[domain.Coordinate]: ship_hp = self.ship_hp_map[ship_type] position: list[domain.Coordinate] = [] - empty_cells = [cell for cell in self.board.cells if cell.ship is None] + empty_cells = [ + cell.coordinate + for cell in self.board.cells + if not self.board.has_ship_at(cell.coordinate) + ] directions = list[domain.Direction](domain.Direction) random.shuffle(empty_cells) random.shuffle(directions) @@ -112,16 +108,16 @@ def place(self, ship_type: rosters.ShipType) -> list[domain.Coordinate]: # Try to found enough empty cells to place the ship in this direction. for _ in range(ship_hp): # Get the next cell in this direction. - next_cell = self.board.get_adjacent_cell(start_cell, direction) + next_cell = start_cell.next(direction) # If there is no cell or the cell is taken, # clear the progress and try another direction. - if next_cell is None or next_cell.ship is not None: + if not self.board.has_cell(next_cell) or self.board.has_ship_at(next_cell): position.clear() break # Otherwise, save the coordinate. - position.append(next_cell.coordinate) + position.append(next_cell) # If there is enough cells to place the ship, return the position. if len(position) == ship_hp: diff --git a/battleship/engine/domain.py b/battleship/engine/domain.py index cba842d..e48fd0b 100644 --- a/battleship/engine/domain.py +++ b/battleship/engine/domain.py @@ -96,6 +96,31 @@ def from_human(cls, coordinate: str) -> "Coordinate": def to_human(self) -> str: return f"{self.col}{self.row}" + def next(self, direction: Direction | DiagonalDirection) -> "Coordinate": + match direction: + case Direction.UP: + return self.up() + case Direction.DOWN: + return self.down() + case Direction.RIGHT: + return self.right() + case Direction.LEFT: + return self.left() + case DiagonalDirection.UP_RIGHT: + up = self.up() + return up.right() + case DiagonalDirection.UP_LEFT: + up = self.up() + return up.left() + case DiagonalDirection.DOWN_RIGHT: + down = self.down() + return down.right() + case DiagonalDirection.DOWN_LEFT: + down = self.down() + return down.left() + case _: + raise ValueError(f"Invalid direction {direction}.") + @dataclasses.dataclass class Cell: @@ -174,36 +199,13 @@ def __repr__(self) -> str: def cells(self) -> list[Cell]: return [cell for row in self.grid for cell in row] - def get_adjacent_cell( - self, cell: Cell, direction: Direction | DiagonalDirection - ) -> Cell | None: - match direction: - case Direction.UP: - coordinate = cell.coordinate.up() - case Direction.DOWN: - coordinate = cell.coordinate.down() - case Direction.RIGHT: - coordinate = cell.coordinate.right() - case Direction.LEFT: - coordinate = cell.coordinate.left() - case DiagonalDirection.UP_RIGHT: - up = cell.coordinate.up() - coordinate = up.right() - case DiagonalDirection.UP_LEFT: - up = cell.coordinate.up() - coordinate = up.left() - case DiagonalDirection.DOWN_RIGHT: - down = cell.coordinate.down() - coordinate = down.right() - case DiagonalDirection.DOWN_LEFT: - down = cell.coordinate.down() - coordinate = down.left() - case _: - raise ValueError(f"Invalid direction {direction}.") + def has_cell(self, coordinate: Coordinate) -> bool: + return 0 <= coordinate.x < self.size and 0 <= coordinate.y < self.size - return self.get_cell(coordinate) + def has_ship_at(self, coordinate: Coordinate) -> bool: + return self.get_cell(coordinate).ship is not None - def get_adjacent_cells(self, cell: Cell, with_diagonals: bool = True) -> list[Cell]: + def get_adjacent_cells(self, coordinate: Coordinate, with_diagonals: bool = True) -> list[Cell]: cells = [] if with_diagonals: @@ -212,28 +214,25 @@ def get_adjacent_cells(self, cell: Cell, with_diagonals: bool = True) -> list[Ce directions = itertools.chain(Direction) for direction in directions: - adjacent_cell = self.get_adjacent_cell(cell, direction) # type: ignore[arg-type] - - if adjacent_cell is not None: + try: + next_coordinate = coordinate.next(direction) # type: ignore[arg-type] + adjacent_cell = self.get_cell(next_coordinate) + except errors.CellOutOfRange: + continue + else: cells.append(adjacent_cell) return cells - def get_cell(self, coordinate: Coordinate) -> Cell | None: - if not (0 <= coordinate.x < self.size and 0 <= coordinate.y < self.size): - return None + def get_cell(self, coordinate: Coordinate) -> Cell: + if not self.has_cell(coordinate): + raise errors.CellOutOfRange(f"Cell at {coordinate} doesn't exist.") return self.grid[coordinate.y][coordinate.x] def has_adjacent_ship(self, coordinate: Coordinate) -> bool: - cell = self.get_cell(coordinate) - - if not cell: - raise errors.CellOutOfRange(f"Cell at {coordinate=} does not exist.") - - adjacent_cells = self.get_adjacent_cells(cell) - - return any([cell.ship is not None for cell in adjacent_cells]) + adjacent_cells = self.get_adjacent_cells(coordinate) + return any([self.has_ship_at(cell.coordinate) for cell in adjacent_cells]) def place_ship( self, coordinates: Collection[Coordinate], ship: Ship, no_adjacent_ships: bool = False @@ -252,10 +251,6 @@ def place_ship( for coordinate in coordinates: cell = self.get_cell(coordinate) - - if cell is None: - raise errors.CellOutOfRange(f"Cell at {coordinate} doesn't exist.") - cell.set_ship(ship) self.ships.append(ship) diff --git a/battleship/tui/strategies.py b/battleship/tui/strategies.py index 7414148..417e230 100644 --- a/battleship/tui/strategies.py +++ b/battleship/tui/strategies.py @@ -321,14 +321,14 @@ def fire(self, position: Collection[str]) -> None: def cancel(self) -> None: pass - def _call_bot_target(self) -> Collection[str]: + def _call_bot_target(self) -> list[str]: if self._game.salvo_mode: count = self._bot_player.ships_alive else: count = 1 - position = self._target_caller.call_out(count=count) - return position + targets = self._target_caller.call_out(count=count) + return targets def _spawn_bot_fleet(self) -> None: for item in self._game.roster: diff --git a/tests/domain/test_board.py b/tests/domain/test_board.py index b64aa81..ab1c754 100644 --- a/tests/domain/test_board.py +++ b/tests/domain/test_board.py @@ -14,12 +14,13 @@ def test_board_find_cells(point: str): @pytest.mark.parametrize("coord", ["A11", "B0", "V5"]) -def test_board_returns_none_if_cell_not_found(coord): +def test_board_raises_none_if_cell_not_found(coord): board = domain.Board() coor = domain.Coordinate.from_human(coord) - assert board.get_cell(coor) is None + with pytest.raises(errors.CellOutOfRange): + board.get_cell(coor) def test_board_places_ship(): @@ -74,50 +75,26 @@ def test_board_shooting(): assert ship.hp == 3 -def test_board_gets_adjacent_cell(): - board = domain.Board(size=5) - cell = domain.Cell(domain.Coordinate.from_human("B1")) - - up = board.get_adjacent_cell(cell, domain.Direction.UP) - down = board.get_adjacent_cell(cell, domain.Direction.DOWN) - right = board.get_adjacent_cell(cell, domain.Direction.RIGHT) - left = board.get_adjacent_cell(cell, domain.Direction.LEFT) - - assert up is None - assert down.coordinate == "B2" - assert right.coordinate == "C1" - assert left.coordinate == "A1" - - -def test_board_finds_ships_in_adjacent_cells(): +@pytest.mark.parametrize( + "coor", ["F4", "G4", "G5", "G6", "G7", "G8", "E8", "F8", "E7", "E6", "E5", "E4"] +) +def test_board_finds_ships_in_adjacent_cells(coor): board = domain.Board() board.place_ship( domain.position_to_coordinates(["F5", "F6", "F7"]), domain.Ship(id="1", hp=3, type="ship") ) - assert board.has_adjacent_ship(domain.Coordinate.from_human("F4")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("G4")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("G5")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("G6")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("G7")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("G8")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("E8")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("F8")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("E7")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("E6")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("E5")) - assert board.has_adjacent_ship(domain.Coordinate.from_human("E4")) - - -def test_board_doesnt_find_ships_in_distant_cells(): + assert board.has_adjacent_ship(domain.Coordinate.from_human(coor)) + + +@pytest.mark.parametrize("coor", ["F3", "F9", "D4"]) +def test_board_doesnt_find_ships_in_distant_cells(coor): board = domain.Board() board.place_ship( domain.position_to_coordinates(["F5", "F6", "F7"]), domain.Ship(id="1", hp=3, type="ship") ) - assert not board.has_adjacent_ship(domain.Coordinate.from_human("F3")) - assert not board.has_adjacent_ship(domain.Coordinate.from_human("F9")) - assert not board.has_adjacent_ship(domain.Coordinate.from_human("D4")) + assert not board.has_adjacent_ship(domain.Coordinate.from_human(coor)) def test_board_finds_ships_in_adjacent_cells_ignoring_cells_out_of_range(): diff --git a/tests/test_ai.py b/tests/test_ai.py index b71b6e8..c8fae41 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -42,9 +42,9 @@ def test_target_caller_targets_adjacent_cells_after_hit_until_all_tried(): caller.provide_feedback([shot]) # Adds 4 adjacent cells to next targets. - assert [t.coordinate.to_human() for t in caller.next_targets] == ["B2", "B4", "C3", "A3"] + assert [t.to_human() for t in caller.next_targets] == ["B2", "B4", "C3", "A3"] assert caller.call_out() == ["B2"] - assert [t.coordinate.to_human() for t in caller.next_targets] == ["B4", "C3", "A3"] + assert [t.to_human() for t in caller.next_targets] == ["B4", "C3", "A3"] # When all next targets are called out, caller starts mixing in random cells. assert caller.call_out(count=4) == ["B4", "C3", "A3", "C9"] From 5086dc48bf00c478d888fb2237ceacc218716dd3 Mon Sep 17 00:00:00 2001 From: Roman Vlasenko Date: Wed, 18 Sep 2024 00:26:08 +0300 Subject: [PATCH 6/9] Remove redundant None check --- battleship/engine/domain.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/battleship/engine/domain.py b/battleship/engine/domain.py index e48fd0b..5359dce 100644 --- a/battleship/engine/domain.py +++ b/battleship/engine/domain.py @@ -258,10 +258,6 @@ def place_ship( def hit_cell(self, coordinate: Coordinate) -> Ship | None: cell = self.get_cell(coordinate) - - if cell is None: - raise errors.CellOutOfRange(f"Cell at {coordinate} doesn't exist.") - cell.hit() return cell.ship From 08473c726941d6f62a374a068ff325780b9e2147 Mon Sep 17 00:00:00 2001 From: Roman Vlasenko Date: Wed, 18 Sep 2024 00:30:22 +0300 Subject: [PATCH 7/9] Include a human representation of coordinate into __repr__ --- battleship/engine/domain.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/battleship/engine/domain.py b/battleship/engine/domain.py index 5359dce..56f2a08 100644 --- a/battleship/engine/domain.py +++ b/battleship/engine/domain.py @@ -68,17 +68,13 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self.x, self.y)) - def up(self) -> "Coordinate": - return Coordinate(self.x, self.y - 1) - - def right(self) -> "Coordinate": - return Coordinate(self.x + 1, self.y) - - def down(self) -> "Coordinate": - return Coordinate(self.x, self.y + 1) + def __repr__(self) -> str: + return f"Coordinate(x={self.x}, y={self.y}, human={self.to_human()})" - def left(self) -> "Coordinate": - return Coordinate(self.x - 1, self.y) + @classmethod + def from_human(cls, coordinate: str) -> "Coordinate": + col, row = parse_coordinate(coordinate) + return Coordinate(ord(col) - ASCII_OFFSET - 1, row - 1) @property def col(self) -> str: @@ -88,14 +84,21 @@ def col(self) -> str: def row(self) -> int: return self.y + 1 - @classmethod - def from_human(cls, coordinate: str) -> "Coordinate": - col, row = parse_coordinate(coordinate) - return Coordinate(ord(col) - ASCII_OFFSET - 1, row - 1) - def to_human(self) -> str: return f"{self.col}{self.row}" + def up(self) -> "Coordinate": + return Coordinate(self.x, self.y - 1) + + def right(self) -> "Coordinate": + return Coordinate(self.x + 1, self.y) + + def down(self) -> "Coordinate": + return Coordinate(self.x, self.y + 1) + + def left(self) -> "Coordinate": + return Coordinate(self.x - 1, self.y) + def next(self, direction: Direction | DiagonalDirection) -> "Coordinate": match direction: case Direction.UP: From 752f73b0de531ec70e71c69386a852b23d2edb66 Mon Sep 17 00:00:00 2001 From: Roman Vlasenko Date: Wed, 18 Sep 2024 00:31:47 +0300 Subject: [PATCH 8/9] Make use of enum.auto and enum.unique --- battleship/engine/domain.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/battleship/engine/domain.py b/battleship/engine/domain.py index 56f2a08..3c7ff3e 100644 --- a/battleship/engine/domain.py +++ b/battleship/engine/domain.py @@ -16,6 +16,7 @@ ASCII_OFFSET = 64 +@enum.unique class Direction(StrEnum): UP = enum.auto() DOWN = enum.auto() @@ -23,6 +24,7 @@ class Direction(StrEnum): LEFT = enum.auto() +@enum.unique class DiagonalDirection(StrEnum): UP_RIGHT = enum.auto() UP_LEFT = enum.auto() @@ -30,9 +32,10 @@ class DiagonalDirection(StrEnum): DOWN_LEFT = enum.auto() +@enum.unique class FiringOrder(StrEnum): - ALTERNATELY = "alternately" - UNTIL_MISS = "until_miss" + ALTERNATELY = enum.auto() + UNTIL_MISS = enum.auto() @dataclasses.dataclass From 9991930d5dd8f79ca39bdf67b98a8083aad1952d Mon Sep 17 00:00:00 2001 From: Roman Vlasenko Date: Wed, 18 Sep 2024 00:46:41 +0300 Subject: [PATCH 9/9] A bit of comments --- battleship/engine/ai.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/battleship/engine/ai.py b/battleship/engine/ai.py index d503bd2..1280d59 100644 --- a/battleship/engine/ai.py +++ b/battleship/engine/ai.py @@ -18,15 +18,22 @@ def call_out(self, *, count: int = 1) -> list[str]: def provide_feedback(self, shots: Iterable[domain.Shot]) -> None: for shot in shots: + # If shot was a hit, we can learn something from it. if shot.hit: assert shot.ship, "Shot was a hit, but no ship present" if shot.ship.destroyed and self.no_adjacent_ships: + # If ship was destroyed and there's "No adjacent ships" + # rule enabled, there's no point in firing cells + # that surrounds the ship - it's impossible to place + # another ship there. coordinates = self._find_cells_around_ship(shot.ship) self.excluded_cells.update(coordinates) self.next_targets.clear() elif not shot.ship.destroyed: - cells = self._find_adjacent_cells(shot.coordinate) + # If ship was hit, but not destroyed, keep on + # firing cells around until it is destroyed. + cells = self._target_ship(shot.coordinate) self.next_targets.extend(cells) def _get_targets(self, count: int) -> list[domain.Coordinate]: @@ -37,12 +44,12 @@ def _get_targets(self, count: int) -> list[domain.Coordinate]: targets.append(next_target) if len(targets) != count: - random_targets = self._find_random_targets(count - len(targets)) + random_targets = self._target_random_cells(count - len(targets)) targets.extend(random_targets) return targets - def _find_random_targets(self, count: int) -> list[domain.Coordinate]: + def _target_random_cells(self, count: int) -> list[domain.Coordinate]: candidates = [ cell.coordinate for cell in self.board.cells @@ -50,7 +57,7 @@ def _find_random_targets(self, count: int) -> list[domain.Coordinate]: ] return random.sample(candidates, k=min(len(candidates), count)) - def _find_adjacent_cells(self, coordinate: domain.Coordinate) -> list[domain.Coordinate]: + def _target_ship(self, coordinate: domain.Coordinate) -> list[domain.Coordinate]: cells = [] for cell_ in self.board.get_adjacent_cells(coordinate, with_diagonals=False):