Practicing objected oriented design. This time, it's designing the TicTacToe game. One difference between the real TicTacToe game is that I've made my TicTacToe game to have variable sized board.
Please review in terms of OOD and I've also left some comments in my code where I want feedback in particular. Thank you very much in advance.
Misc
from enum import Enum
from typing import Union, Optional, Callable
class InvalidInput(Exception):
pass
class SpotTaken(Exception):
def __init__(self, pos_x: int, pos_y: int) -> None:
super().__init__(f'({pos_x}, {pos_y}) is already taken!')
class GameNotRunning(Exception):
def __init__(self) -> None:
super().__init__('Game is currently not running.')
class InvalidBoardSize(Exception):
def __iter__(self) -> None:
super().__init__('Board size must be a positive integer.')
TicTacToe
class TicTacToe:
DEFAULT_BOARD_SIZE = 3
# Should this be inside the TicTacToe class or outside?
class Turn(str, Enum):
X = 'X'
O = 'O'
def __str__(self) -> str:
return self.value
# Should this be inside the TicTacToe class or outside?
class State(Enum):
RUNNING = 0
TURN_WON = 1
TIE = 2
def __init__(self, board_size: Optional[int] = None) -> None:
if board_size is not None and board_size <= 0:
raise InvalidBoardSize()
self.board_size = board_size or self.DEFAULT_BOARD_SIZE
self.board: list[list[Union[None, str]]] = [[None] * self.board_size for _ in range(self.board_size)]
self.turn = self.Turn.X
self.state = self.State.RUNNING
self.spots_left = self.board_size * self.board_size
def do_turn(self, pos_x: int, pos_y: int) -> None:
if self.state != self.State.RUNNING:
raise GameNotRunning()
if pos_x < 0 or pos_x >= self.board_size or pos_y < 0 or pos_y >= self.board_size:
raise InvalidInput('pos_x or pos_y is out of bounds!')
if self.board[pos_x][pos_y]:
raise SpotTaken(pos_x=pos_x, pos_y=pos_y)
self.board[pos_x][pos_y] = self.turn
self.spots_left -= 1
self._check_state()
if self.state == self.State.RUNNING:
self._change_turn()
# Optimally, I don't have to check all 8 possible ways.
# Technically, I only need to check the vertical of the column pos_y, the horizontal of the row pos_x,
# and the diagonals if the (pos_x, pos_y) overlaps with any of the points on the diagonal path.
# However, just to have a simpler code, just do the check on all 8 ways always.
# Since, these are such simple operations, it should not affect the performance really, anyway.
def _check_state(self) -> None:
# vertical check
for row in range(self.board_size):
winning_line_found = True
for col in range(self.board_size):
if self.board[row][col] != self.turn:
winning_line_found = False
break
if winning_line_found:
self.state = self.State.TURN_WON
return
# horizontal check
for col in range(self.board_size):
winning_line_found = True
for row in range(self.board_size):
if self.board[row][col] != self.turn:
winning_line_found = False
break
if winning_line_found:
self.state = self.State.TURN_WON
return
# top left to bottom right diagonal check
winning_line_found = True
for i in range(self.board_size):
if self.board[i][i] != self.turn:
winning_line_found = False
if winning_line_found:
self.state = self.State.TURN_WON
return
# top right to bottom left diagonal check
winning_line_found = True
for i in range(self.board_size):
if self.board[i][self.board_size - i - 1] != self.turn:
winning_line_found = False
if winning_line_found:
self.state = self.State.TURN_WON
return
if self.spots_left == 0:
self.state = self.State.TIE
def _change_turn(self) -> None:
self.turn = self.Turn.X if self.turn == self.Turn.O else self.Turn.O
def _get_status_msg(self) -> str:
if self.state == self.State.RUNNING:
msg = f'{self.turn}\'s turn.'
elif self.state == self.State.TURN_WON:
msg = f'{self.turn} WON!'
else:
msg = 'It\'s a TIE'
return msg
def _get_board_display(self) -> str:
return '\n'.join(str([str(val) if val else ' ' for val in row]) for row in self.board)
def __str__(self) -> str:
return (f'{self._get_board_display()}\n'
f'{self._get_status_msg()}')
TicTacToe CLI Manager
class TicTacToeCLIManager:
class State(Enum):
DETERMINE_BOARD_SIZE = 0
RUN_GAME = 1
ASK_RESTART = 2
QUIT = 3
def __init__(self) -> None:
self.tic_tac_toe: Union[TicTacToe, None] = None
self.state = self.State.DETERMINE_BOARD_SIZE
def run(self) -> None:
while self.state != self.State.QUIT:
self.STATE_TO_HANDLER[self.state](self)
def _handle_determine_board_size_state(self) -> None:
print('Determine the size of the board')
board_size = None
while board_size is None:
user_input = input()
try:
board_size = int(user_input)
except ValueError:
print('The size of the board must be an integer')
try:
self.tic_tac_toe = TicTacToe(board_size=board_size)
except InvalidBoardSize:
print('The size of the board has to be a positive integer')
return
print(self.tic_tac_toe)
self.state = self.State.RUN_GAME
def _handle_run_game_state(self) -> None:
user_input = input()
user_input_split = user_input.split(' ')
try:
pos_x, pos_y = int(user_input_split[0]), int(user_input_split[1])
except (ValueError, IndexError):
print('Inputs must have at least two integers separated by a space')
print(self.tic_tac_toe)
return
try:
self.tic_tac_toe.do_turn(pos_x=pos_x, pos_y=pos_y)
except (InvalidInput, SpotTaken):
print('Pick the position correctly, please')
print(self.tic_tac_toe)
if self.tic_tac_toe.state != self.tic_tac_toe.State.RUNNING:
self.state = self.State.ASK_RESTART
def _handle_ask_restart_state(self) -> None:
print('Do you want to replay? If so, type y')
user_input = input()
if user_input == 'y':
self.state = self.State.DETERMINE_BOARD_SIZE
else:
self.state = self.State.QUIT
# I want to put this constant STATE_TO_HANDLER at the top of the class. I don't like constants being at the bottom.
# However, if I put this above these methods, it cannot find the references to the methods.
# What's the best practice regarding this?
#
# I wanted to put typing on the Callable's parameter and return types. Eg. Callable[[TicTacToeCLIManager], None]
# However, the parameter type would be TicTacToeCLIManager and it cannot find the reference here
# since the TicTacToeCLIManager definition is not finished.
# What's the best practice regarding this, too?
STATE_TO_HANDLER: dict[State, Callable] = {
State.DETERMINE_BOARD_SIZE: _handle_determine_board_size_state,
State.RUN_GAME: _handle_run_game_state,
State.ASK_RESTART: _handle_ask_restart_state,
}
runner
def test():
TicTacToeCLIManager().run()