from dataclasses import dataclass from enum import Enum, auto from typing import Any, Dict, List, Optional class Field(Enum): X = auto() O = auto() empty = auto() def __str__(self): """ Convert the field to a string. """ match self: case Field.X: return "X" case Field.O: return "O" case Field.empty: return "" @dataclass(frozen=True) class TicTacToe: field_1 : Field field_2 : Field field_3 : Field field_4 : Field field_5 : Field field_6 : Field field_7 : Field field_8 : Field field_9 : Field def __write(self, index : int, value : Field) -> "TicTacToe": """ Create a new copy where one field has been written with any value. :param index: The field number to replace. (1-9) :type index: int :param value: The field value to write. :type value: Field :return: A new copy where the value was written. :rtype: TicTacToe :raises ValueError: The field index has already been written to. :raises KeyError: The index is an invalid number. """ current_value = self.to_dict().get(str(index), None) if current_value is None: raise KeyError( f"Index to write to should be 1-9, not {index}" ) elif current_value != Field.empty: raise ValueError( f"Field should be empty, not {current_value}" ) match index: case 1: return TicTacToe( field_1=value, field_2=self.field_2, field_3=self.field_3, field_4=self.field_4, field_5=self.field_5, field_6=self.field_6, field_7=self.field_7, field_8=self.field_8, field_9=self.field_9, ) case 2: return TicTacToe( field_1=self.field_1, field_2=value, field_3=self.field_3, field_4=self.field_4, field_5=self.field_5, field_6=self.field_6, field_7=self.field_7, field_8=self.field_8, field_9=self.field_9, ) case 3: return TicTacToe( field_1=self.field_1, field_2=self.field_2, field_3=value, field_4=self.field_4, field_5=self.field_5, field_6=self.field_6, field_7=self.field_7, field_8=self.field_8, field_9=self.field_9, ) case 4: return TicTacToe( field_1=self.field_1, field_2=self.field_2, field_3=self.field_3, field_4=value, field_5=self.field_5, field_6=self.field_6, field_7=self.field_7, field_8=self.field_8, field_9=self.field_9, ) case 5: return TicTacToe( field_1=self.field_1, field_2=self.field_2, field_3=self.field_3, field_4=self.field_4, field_5=value, field_6=self.field_6, field_7=self.field_7, field_8=self.field_8, field_9=self.field_9, ) case 6: return TicTacToe( field_1=self.field_1, field_2=self.field_2, field_3=self.field_3, field_4=self.field_4, field_5=self.field_5, field_6=value, field_7=self.field_7, field_8=self.field_8, field_9=self.field_9, ) case 7: return TicTacToe( field_1=self.field_1, field_2=self.field_2, field_3=self.field_3, field_4=self.field_4, field_5=self.field_5, field_6=self.field_6, field_7=value, field_8=self.field_8, field_9=self.field_9, ) case 8: return TicTacToe( field_1=self.field_1, field_2=self.field_2, field_3=self.field_3, field_4=self.field_4, field_5=self.field_5, field_6=self.field_6, field_7=self.field_7, field_8=value, field_9=self.field_9, ) case 9: return TicTacToe( field_1=self.field_1, field_2=self.field_2, field_3=self.field_3, field_4=self.field_4, field_5=self.field_5, field_6=self.field_6, field_7=self.field_7, field_8=self.field_8, field_9=value, ) case _: raise KeyError( f"Index to write to should be 1-9, not {index}" ) def action_name(self): return "tic-tac-toe" def count_o(self) -> int: """ Count the number of O's on the board. """ return list(self.to_dict().values()).count(str(Field.O)) def count_x(self) -> int: """ Count the number of X's on the board. """ return list(self.to_dict().values()).count(str(Field.X)) @classmethod def empty(cls): return cls( field_1=Field.empty, field_2=Field.empty, field_3=Field.empty, field_4=Field.empty, field_5=Field.empty, field_6=Field.empty, field_7=Field.empty, field_8=Field.empty, field_9=Field.empty, ) def move_default(self) -> "TicTacToe": """ Have a player take a "default" move. They'll take this move whenever their response is invalid, or when they take too long to decide, or when they're no longer accessible. :return: New state of the board. :rtype: TicTacToe :raises ValueError: When no more moves exist. By this point, someone should've already won. """ symbol = Field.X if self.player_to_move() == 1 else Field.O for i in range(9): try: out = self.__write(i+1, symbol) except ValueError: # Field already occupied! Move to the next option. pass else: return out else: # All moves seem invalid! raise ValueError( "No legal moves exist anymore on this tic-tac-toe board." ) def move(self, payload : Optional[Dict[str, Any]] = None) -> "TicTacToe": """ Have a player make a move. Based on this information, update the game. :param payload: Dictionary containing the player's response. :type payload: Optional[Dict[str, Any]] :return: New state of the board. :rtype: TicTacToe :raises ValueError: When no more moves exist. By this point, someone should've already won. """ symbol = Field.X if self.player_to_move() == 1 else Field.O # Extract field to place symbol at move = 1 if isinstance(payload, dict): move = payload.get("move", 1) if not isinstance(move, int): move = 1 move = min(9, max(1, move)) try: out = self.__write(move, symbol) except ValueError: # Invalid move! We'll try any other move. pass else: return out # The player chose an invalid move. return self.move_default() def player_to_move(self) -> int: """ Return which player needs to move. """ return 1 if self.count_x() <= self.count_o() else 2 def to_dict(self) -> Dict[str, Any]: return { "1": str(self.field_1), "2": str(self.field_2), "3": str(self.field_3), "4": str(self.field_4), "5": str(self.field_5), "6": str(self.field_6), "7": str(self.field_7), "8": str(self.field_8), "9": str(self.field_9), } def winner(self) -> int | None: """ Returns whether the board indicates that there's a winner. :return: The winning player, zero in case of a tie, or None if there's no winner yet. :rtype: int | None """ win_lines = [ [ 1, 2, 3, ], [ 4, 5, 6, ], [ 7, 8, 9, ], [ 1, 4, 7, ], [ 2, 5, 8, ], [ 3, 6, 9, ], [ 1, 5, 9, ], [ 3, 5, 7, ], ] d = self.to_dict() for player, symbol in [ ( 1, Field.X ), ( 2, Field.O ) ]: for win_line in win_lines: if all(d[str(w)] == symbol for w in win_line): return player else: # Check for draw if all(item != Field.empty for item in d.values()): return 0 return None