307 lines
9.9 KiB
Python
307 lines
9.9 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 != str(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", None)
|
|
|
|
if not isinstance(move, int):
|
|
move = None
|
|
else:
|
|
move = move if 1 <= move <= 9 else None
|
|
|
|
if move is not None:
|
|
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, str(Field.X) ), ( 2, str(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 != "" for item in d.values()):
|
|
return 0
|
|
|
|
return None
|
|
|