Bot-Man-Toe/pyclient/games/tic_tac_toe.py

302 lines
9.6 KiB
Python

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, 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:
return None