Last active
March 20, 2022 08:47
-
-
Save Per48edjes/c8bfe06e29cc7f06b543b16e6ebd164b to your computer and use it in GitHub Desktop.
Tic-Tac-Toe game for Recurse Center code interview
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Recurse Center Programming Task: Tic-Tac-Toe | |
Author: Ravi Dayabhai | |
""" | |
from __future__ import annotations | |
import copy | |
import math | |
import os | |
import sys | |
from typing import Set, Tuple | |
class Cell: | |
def __init__(self, state: str = None): | |
self._state = state | |
@property | |
def state(self): | |
return self._state | |
@state.setter | |
def state(self, new_state: str): | |
self._state = new_state | |
def __str__(self): | |
return self.state if self.state else " " | |
class Board: | |
def __init__(self, board_size: int): | |
self._board_size = board_size | |
self._grid = [[Cell() for _ in range(board_size)] for _ in range(board_size)] | |
@property | |
def board_size(self): | |
return self._board_size | |
@property | |
def grid(self): | |
return self._grid | |
@property | |
def available_spaces(self) -> Set[Tuple[int, int]]: | |
return { | |
(row, col) | |
for row, row_cells in enumerate(self.grid) | |
for col, _ in enumerate(row_cells) | |
if not self.grid[row][col].state | |
} | |
@property | |
def winner(self) -> [int, None]: | |
rows_container = [0] * self.board_size | |
columns_container = [0] * self.board_size | |
diagonal_container = [0] * self.board_size | |
opposite_diagonal_container = [0] * self.board_size | |
# Populate containers | |
for row, row_cells in enumerate(self.grid): | |
for col, cell in enumerate(row_cells): | |
# + corresponds to 'X'/Player 1; - corresponds to 'O'/Player 2 | |
player_increment = ( | |
1 if cell.state == "X" else -1 if cell.state == "O" else 0 | |
) | |
rows_container[row] += player_increment | |
columns_container[col] += player_increment | |
if row == col: | |
diagonal_container[row] += player_increment | |
if row + col + 1 == self.board_size: | |
opposite_diagonal_container[row] += player_increment | |
container_totals = { | |
*rows_container, | |
*columns_container, | |
sum(diagonal_container), | |
sum(opposite_diagonal_container), | |
} | |
# Player 1 winning condition | |
if self.board_size in container_totals: | |
return 1 | |
# Player 2 winning condition | |
elif -self.board_size in container_totals: | |
return 2 | |
# Game has no winner yet or cat's game | |
else: | |
return None | |
@property | |
def is_terminal_state(self): | |
return True if self.winner or not self.available_spaces else False | |
@property | |
def utility(self): | |
return 1 if self.winner == 1 else -1 if self.winner == 2 else 0 | |
def update_grid(self, new_state: str, row: int, col: int) -> None: | |
self.grid[row][col].state = new_state | |
def __str__(self) -> str: | |
horizontal_divider = "\n" + ((self.board_size - 1) * "---+") + "---\n" | |
return horizontal_divider.join( | |
" " + " | ".join(str(cell) for cell in row) for row in self.grid | |
) | |
@staticmethod | |
def next_board(board: Board, new_state, row: int, col: int) -> Board: | |
new_board = copy.deepcopy(board) | |
new_board.update_grid(new_state, row, col) | |
return new_board | |
class Player: | |
def __init__(self, number: int, marker: str, board: Board): | |
self.number = number | |
self.marker = marker | |
def select_space(self, board: Board) -> Tuple[int]: | |
while True: | |
try: | |
row, col = tuple( | |
map( | |
int, | |
input( | |
f"Player {self.number} Choose location (zero-indexed from top left space) by entering desired '<row> <column>': " | |
).split(), | |
) | |
) | |
if board.grid[row][col].state: | |
raise ValueError | |
return row, col | |
except ValueError: | |
print( | |
"Space already taken or invalid coordinate; try a different space." | |
) | |
except IndexError: | |
print("One or more indices not on board; try a different space.") | |
def make_move(self, board: Board, row: int, col: int) -> None: | |
board.update_grid(self.marker, row, col) | |
class AI(Player): | |
def select_space(self, board: Board) -> Tuple[int]: | |
if self.number == 1: | |
max_utility = -math.inf | |
for space in board.available_spaces: | |
space_utility = AI.minimizer_utility( | |
Board.next_board(board, self.marker, *space) | |
) | |
if space_utility > max_utility: | |
max_utility = space_utility | |
row, col = space | |
else: | |
min_utility = math.inf | |
for space in board.available_spaces: | |
space_utility = AI.maximizer_utility( | |
Board.next_board(board, self.marker, *space) | |
) | |
if space_utility < min_utility: | |
min_utility = space_utility | |
row, col = space | |
return row, col | |
@staticmethod | |
def minimizer_utility( | |
board: Board, alpha: int = -math.inf, beta: int = math.inf | |
) -> int: | |
if board.is_terminal_state: | |
return board.utility | |
else: | |
utility = math.inf | |
for space in board.available_spaces: | |
space_utility = AI.maximizer_utility( | |
Board.next_board(board, "O", *space), alpha, beta | |
) | |
utility = min(utility, space_utility) | |
if space_utility <= alpha: | |
return space_utility | |
beta = min(beta, space_utility) | |
return utility | |
@staticmethod | |
def maximizer_utility( | |
board: Board, alpha: int = -math.inf, beta: int = math.inf | |
) -> int: | |
if board.is_terminal_state: | |
return board.utility | |
else: | |
utility = -math.inf | |
for space in board.available_spaces: | |
space_utility = AI.minimizer_utility( | |
Board.next_board(board, "X", *space), alpha, beta | |
) | |
utility = max(utility, space_utility) | |
if space_utility >= beta: | |
return space_utility | |
alpha = max(alpha, space_utility) | |
return utility | |
class Game: | |
def __init__(self): | |
self.board = self.choose_board() | |
self.player_1, self.player_2 = self.choose_players() | |
def choose_board(self) -> Board: | |
while True: | |
try: | |
board_size = int( | |
input( | |
"Choose the game's board size, n (i.e., n x n grid; n = 3 by default): " | |
) | |
or 3 | |
) | |
if board_size >= 1: | |
return Board(board_size) | |
else: | |
raise ValueError | |
except ValueError: | |
print( | |
"Board must by at least 1x1 and can only accept postive integers." | |
) | |
def choose_players(self) -> Tuple[Player, AI]: | |
while True: | |
try: | |
is_single_player_str = input("Play against computer? (y/n) ") | |
if is_single_player_str not in ("y", "n", "Y", "N"): | |
raise ValueError("Choose 'y' (yes) or 'n' (no).") | |
is_single_player = True if is_single_player_str in ("Y", "y") else False | |
break | |
except ValueError: | |
continue | |
if is_single_player: | |
while True: | |
try: | |
human_player_num = int( | |
input("Which player would you like to play as? (1 or 2) ") | |
) | |
if human_player_num not in (1, 2): | |
raise ValueError | |
break | |
except ValueError: | |
print("Choose either 1 or 2.") | |
return ( | |
(Player(1, "X", self.board), AI(2, "O", self.board)) | |
if human_player_num == 1 | |
else (AI(1, "X", self.board), Player(2, "O", self.board)) | |
) | |
else: | |
return Player(1, "X", self.board), Player(2, "O", self.board) | |
def play(self) -> None: | |
os.system("clear") | |
print(self.board) | |
# Game loop | |
while True: | |
# Each round alternates players | |
for player in (self.player_1, self.player_2): | |
# Update state | |
row, col = player.select_space(self.board) | |
player.make_move(self.board, row, col) | |
# Display refresh | |
os.system("clear") | |
print(self.board) | |
# Endgame | |
if self.board.winner: | |
print(f"Player {self.board.winner} won!") | |
sys.exit() | |
if not self.board.available_spaces: | |
print("Cat's game!") | |
sys.exit() | |
def main() -> None: | |
g = Game() | |
g.play() | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment