Edit: I forgot to mention at first, the way I implemented the minimax, instead of having one player minimize and one player maximize, both players maximize and then return the negative of their value. Also, is there any concern with creating multiple instances of the Board class in terms of Board.board being mutable?
Edit 2: Added an example usage to the code
"(tictactoe_framework.py) Module for representing a tictactoe board"
from math import cos
from random import randint
from typing import Self
from line_profiler import profile
class Board:
"""
Represents the state of a tictactoe board.
Attributes:
- self.board (list[int]): Represents the state of the board in a
9-element flat array where moves by X are represented by 1,
moves by O are represented by -1, and empty spaces are represented
by 0.
"""
def __init__(self, board: list[int] | None = None) -> None:
if board is None:
board = [0] * 9
self.board: list[int] = board
@property
@profile
def check_win(self) -> int | None:
"""
(Property) Checks all possible winning combinations of board spaces and returns
based on whether the current board state has a winner, is a draw, or
is ongoing.
Returns (int | None):
Int:
- 1 if X has won
- -1 if O has won
- 0 if game is a draw
None:
- None if game is ongoing
"""
win_indices: list[list[int]] = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], # Rows
[0, 3, 6], [1, 4, 7], [2, 5, 8], # Columns
[0, 4, 8], [2, 4, 6], # Diagonals
]
for win_condition in win_indices:
line_sum = (self.board[win_condition[0]]
+ self.board[win_condition[1]]
+ self.board[win_condition[2]])
if abs(line_sum) == 3:
return line_sum // 3
if 0 in self.board:
return None
return 0
@property
def player_to_move(self) -> int:
"""
(Property) Determines which player's turn it is to move by finding
the parity of the total number of moves.
Returns (int):
- 1 if player X is to move
- -1 if player O is to move
"""
num_of_moves: int = sum(abs(space) for space in self.board)
if num_of_moves % 2 == 0:
return 1
return -1
@property
def ideal_move(self) -> int | None:
"""
(Property) Determines the ideal move for the current player using the minimax algorithm.
Returns (int | None):
Int:
- The index of the ideal move if the game has not ended yet
None:
- None if the game has already ended
"""
@profile
def minimax(board: Board = self, depth: int = 0) -> int | None:
win_state: int | None = board.check_win
if (not depth) and (win_state is not None):
return None
if (depth) and (win_state is not None):
return -1 * win_state * board.player_to_move
if win_state is None:
move_list: list[int] = []
for idx, state in enumerate(board.board):
if not state:
move_list.append(idx)
value_list: list[int | None] = [0 for _ in move_list]
for idx, move in enumerate(move_list):
board.move(move)
value_list[idx] = minimax(board, depth + 1)
board.board[move] = 0
if depth:
return -1 * max(i for i in value_list if i is not None)
max_value: int = max(i for i in value_list if i is not None)
max_indices: list[int] = [
idx for idx, val in enumerate(value_list) if max_value == val
]
board_state_int: int = sum(
(2**j) * abs(board.board[j]) + 4 for j in range(len(value_list))
)
board_state_seed: int = (
int(100 * abs(cos(board_state_int)) % len(max_indices)) - 1
)
return move_list[max_indices[board_state_seed]]
return depth
return minimax()
def move(self, space: int | None) -> None:
"""
Plays a move in a space on the board. Raises an error if the move is not valid.
Returns:
None
"""
if space is not None:
try:
assert self.board[space] == 0
self.board[space] = self.player_to_move
except AssertionError:
print(
"ERROR: Invalid Move\n"
f"Boardstate: {self.board}\n"
f"Attempted move: {space}"
)
def __str__(self) -> str:
disp_dict: dict[int, str] = {0: " ", 1: "X", -1: "O"}
disp_board: list[str] = [disp_dict[space] for space in self.board]
disp_string: str = (
f"-----\n"
f"{disp_board[0]} {disp_board[1]} {disp_board[2]}\n"
f"{disp_board[3]} {disp_board[4]} {disp_board[5]}\n"
f"{disp_board[6]} {disp_board[7]} {disp_board[8]}\n"
f"-----"
)
return disp_string
@classmethod
def random(cls) -> Self:
"""
Creates an instance of the class with board state with a
random number of moves made by both players.
Returns (Self):
- An instance of Board representing a random boardstate
"""
num_of_moves: int = randint(0, 8)
random_board: Self = cls()
for _ in range(num_of_moves):
while True:
move: int = randint(0, 8)
if random_board.board[move] == 0:
random_board.board[move] = random_board.player_to_move
if random_board.check_win is None:
break
return random_board
if __name__ == "__main__":
x = Board()
print(x)
while x.check_win is None:
x.move(x.ideal_move)
print(x)