From 7f6a01e2fb7e6284088b69490811fce867e95667 Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Mon, 25 Apr 2022 17:37:04 -0700 Subject: [PATCH] Implement AI --- battleship/__main__.py | 13 ++-- battleship/ai.py | 126 ++++++++++++++++++++++++++++++++++++++- battleship/battleship.py | 16 ++--- battleship/human.py | 8 ++- battleship/player.py | 16 ++--- battleship/ui.py | 17 ++++-- 6 files changed, 166 insertions(+), 30 deletions(-) diff --git a/battleship/__main__.py b/battleship/__main__.py index c63607d..1b2af0e 100644 --- a/battleship/__main__.py +++ b/battleship/__main__.py @@ -1,12 +1,17 @@ +from .ai import AIPlayer from .battleship import play_battleship from .board import Board from .human import HumanPlayer +from .ship import ShipType -board = Board(10, 10) -p1 = HumanPlayer("Player 1", board) -p2 = HumanPlayer("Player 2", board) +board = Board(7, 7) +# p1 = HumanPlayer("Player 1", board) +# p2 = HumanPlayer("Player 2", board) +p1 = AIPlayer("Player 1", board) +p2 = AIPlayer("Player 2", board) try: - play_battleship(p1, p2) + ship_types = list(sorted(ShipType, key=lambda t: t.size, reverse=True)) + play_battleship(p1, p2, ship_types) except KeyboardInterrupt: print("\n") diff --git a/battleship/ai.py b/battleship/ai.py index 221c6a8..7ee1577 100644 --- a/battleship/ai.py +++ b/battleship/ai.py @@ -1,7 +1,129 @@ from __future__ import annotations -from .player import Player +from enum import Enum, auto +import random +from typing import Optional + +from .bs_types import Coordinate +from .player import Player, PlacementError +from .ship import ShipType, Ship, ShipOrientation + + +class CoordStatus(Enum): + UNKNOWN = auto() + HIT = auto() + SUNK = auto() + MISS = auto() + + +class SearchDirection(Enum): + UP = (0, -1) + DOWN = (0, 1) + LEFT = (-1, 0) + RIGHT = (1, 0) + + def move(self, coordinate: Coordinate, distance=1) -> Coordinate: + x, y = coordinate + dx, dy = self.value + dx *= distance + dy *= distance + return x + dx, y + dy + + def inverse(self) -> SearchDirection: + return { + SearchDirection.UP: SearchDirection.DOWN, + SearchDirection.DOWN: SearchDirection.UP, + SearchDirection.LEFT: SearchDirection.RIGHT, + SearchDirection.RIGHT: SearchDirection.LEFT, + }[self] class AIPlayer(Player): - pass \ No newline at end of file + def place_ship(self, ship_type: ShipType) -> Ship: + while True: + orientation = random.choice(list(ShipOrientation)) + max_x, max_y = self.board.width, self.board.height + if orientation == ShipOrientation.RIGHT: + max_x -= ship_type.size + else: + max_y -= ship_type.size + + coords = random.randrange(0, max_x), random.randrange(0, max_y) + ship = Ship(coords, ship_type, orientation) + + try: + self.validate_placement(ship) + return ship + except PlacementError: + pass + + def guess(self, opponent: Player) -> Coordinate: + for guess in self.guesses: + if self.coord_status(guess, opponent) == CoordStatus.HIT: + return self.guess_near(guess, opponent) + + while True: + guess = random.choice(opponent.board.columns), random.choice(opponent.board.rows) + if guess in self.guesses: + continue + + return guess + + def coord_status(self, coordinate: Coordinate, opponent: Player) -> CoordStatus: + if coordinate not in self.guesses: + return CoordStatus.UNKNOWN + + ship = opponent.ship_at(coordinate) + + if not ship: + return CoordStatus.MISS + elif ship.sunk(self.guesses): + return CoordStatus.SUNK + else: + return CoordStatus.HIT + + def guess_near(self, coordinate: Coordinate, opponent: Player) -> Coordinate: + # First, look for the directions in which we've found a hit one cell away + promising_directions = [ + direction for direction in SearchDirection + if self.coord_status(direction.move(coordinate, 1), opponent) == CoordStatus.HIT + ] + # Failing that, search in the opposite direction of found hits + reverse_directions = [direction.inverse() for direction in promising_directions + if direction.inverse() not in promising_directions] + + # As a last resort, search in other directions in random order + remaining_directions = [direction for direction in SearchDirection + if direction not in promising_directions + reverse_directions] + random.shuffle(remaining_directions) + + search_order = promising_directions + reverse_directions + remaining_directions + + for direction in search_order: + candidate = self.try_direction(coordinate, direction, opponent) + if candidate: + return candidate + + def try_direction( + self, + coordinate: Coordinate, + direction: SearchDirection, + opponent: Player + ) -> Optional[Coordinate]: + """ + Search for candidate cells in a given direction from a known hit. + """ + for distance in range(1, 5): + candidate = direction.move(coordinate, distance) + status = self.coord_status(candidate, opponent) + + # We went off the board! Time to stop. + if not opponent.board.valid(candidate): + return None + + # We've hit a dead end in this direction + if status in [CoordStatus.MISS, CoordStatus.SUNK]: + return None + + if status == CoordStatus.UNKNOWN: + return candidate diff --git a/battleship/battleship.py b/battleship/battleship.py index aae104e..c746760 100644 --- a/battleship/battleship.py +++ b/battleship/battleship.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import List + from .player import Player from .ship import ShipType from .ui import clear_screen, format_board, opponent_cell_fn @@ -9,26 +11,24 @@ def play_turn(player: Player, opponent: Player): guess = player.guess(opponent) player.guesses.add(guess) - ship = opponent.ship_at(guess) - clear_screen() - print(format_board(opponent_cell_fn(player, opponent), opponent.board)) + print(format_board(opponent_cell_fn(player, opponent, guess), opponent.board)) print() + ship = opponent.ship_at(guess) if not ship: - print("Miss!") + print(f"{player.name}: Miss!") elif ship.sunk(player.guesses): - print(f"{player.name} sunk the enemy {ship.name}") + print(f"{player.name} sunk the enemy {ship.name}!") else: - print("Hit!") + print(f"{player.name}: Hit!") print("\n") input("Press ENTER to continue.") -def play_battleship(player_1: Player, player_2: Player): +def play_battleship(player_1: Player, player_2: Player, ship_types: List[ShipType]): clear_screen() - ship_types = list(sorted(ShipType, key=lambda t: t.size, reverse=True))[-1:] player_1.place_ships(ship_types) player_2.place_ships(ship_types) diff --git a/battleship/human.py b/battleship/human.py index 4b04fe9..5a791ac 100644 --- a/battleship/human.py +++ b/battleship/human.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import List from .bs_types import Coordinate -from .player import Player +from .player import Player, PlacementError from .ship import Ship, ShipType from .ui import read_coordinate, format_board, player_cell_fn, read_orientation, opponent_cell_fn, \ clear_screen @@ -31,9 +31,11 @@ class HumanPlayer(Player): ship = Ship(coord, ship_type, orientation) - if self.validate_placement(ship): + try: + self.validate_placement(ship) return ship - else: + except PlacementError as e: + print(e.message + "\n") print("Press ENTER to try again.\n") input() diff --git a/battleship/player.py b/battleship/player.py index feae8b1..4217507 100644 --- a/battleship/player.py +++ b/battleship/player.py @@ -8,6 +8,12 @@ from .bs_types import Coordinate from .ui import format_coordinate +class PlacementError(Exception): + @property + def message(self) -> str: + return self.args[0] + + class Player: def __init__(self, name: str, board: Board): self.name = name @@ -24,17 +30,13 @@ class Player: Return a new ship of the given type which does not collide with any existing ships. """ - def validate_placement(self, ship: Ship) -> bool: + def validate_placement(self, ship: Ship): if not all(self.board.valid(coord) for coord in ship.coordinates): - print("Ship does not fit on the board.\n") - return False + raise PlacementError("Ship does not fit on the board.") for other in self.ships: if ship.coordinates.intersection(other.coordinates): - print(f"Ship collides with {other.name} at {format_coordinate(other.origin)}.\n") - return False - - return True + raise PlacementError(f"Ship collides with {other.name} at {format_coordinate(other.origin)}.") def guess(self, opponent: Player) -> Coordinate: """ diff --git a/battleship/ui.py b/battleship/ui.py index 05650bb..70bdaa4 100644 --- a/battleship/ui.py +++ b/battleship/ui.py @@ -3,6 +3,7 @@ from __future__ import annotations import os import re from string import ascii_uppercase +from typing import Optional from .board import Board from .ship import Ship, ShipOrientation, ShipType @@ -69,7 +70,7 @@ def player_cell_fn(player) -> CellFn: return _cell -def opponent_cell_fn(player, opponent) -> CellFn: +def opponent_cell_fn(player, opponent, latest: Optional[Coordinate] = None) -> CellFn: """ Creates a cell renderer for a player's view of their opponent's board. """ @@ -80,12 +81,16 @@ def opponent_cell_fn(player, opponent) -> CellFn: ship = opponent.ship_at(coordinate) if not ship: - return "x" - - if ship.sunk(player.guesses): - return ship.ship_type.symbol + symbol = "x" + elif ship.sunk(player.guesses): + symbol = ship.ship_type.symbol else: - return "*" + symbol = "*" + + if coordinate == latest: + symbol = f"({symbol})" + + return symbol return _cell