Skip to content

Instantly share code, notes, and snippets.

@thomasmarwitz
Last active February 2, 2024 18:41
Show Gist options
  • Save thomasmarwitz/028a0b4c8578010b06899dd15a617bbc to your computer and use it in GitHub Desktop.
Save thomasmarwitz/028a0b4c8578010b06899dd15a617bbc to your computer and use it in GitHub Desktop.
Simple Tic Tac Toe
from typing import Protocol, TypeAlias, Callable
from dataclasses import dataclass
from enum import Enum
import random
import re
class CellContent(Enum):
EMPTY = " "
CROSS = "X"
CIRCLE = "O"
@dataclass(frozen=True)
class Coordinate:
x: int
y: int
def __repr__(self) -> str:
return f"(x={self.x}, y={self.y})"
@dataclass(frozen=True)
class Cell:
coordinate: Coordinate
content: CellContent
Line: TypeAlias = tuple[Cell]
@dataclass(frozen=True)
class Grid:
_rows: tuple[Line]
@classmethod
def init_empty(cls, dimension: int) -> "Grid":
_rows = []
for y in range(dimension):
_rows.append(
tuple(
Cell(Coordinate(x=x, y=y), CellContent.EMPTY)
for x in range(dimension)
)
)
return cls(tuple(_rows))
def insert(self, cell: Cell) -> "Grid":
rows = []
for y in range(self.dim):
row = []
for x in range(self.dim):
to_insert = self._rows[y][x]
if to_insert.coordinate == cell.coordinate:
to_insert = cell
row.append(to_insert)
rows.append(tuple(row))
return Grid(tuple(rows))
def is_coordinate_within(self, coordinate: Coordinate) -> bool:
return 0 <= coordinate.x < self.dim and 0 <= coordinate.y < self.dim
def is_cell_empty(self, coordinate: Coordinate) -> bool:
return self[coordinate].content == CellContent.EMPTY
def get_diagonals(self) -> tuple[Line, Line]:
# 1. diagonal (0,0), (1,1), (2,2)
first_diagonal = tuple(self._rows[i][i] for i in range(self.dim))
# 2. diagonal: (0,2), (1,1), (2,0)
second_diagonal = tuple(
self._rows[i][self.dim - i - 1] for i in range(self.dim)
)
return first_diagonal, second_diagonal
def get_rows(self) -> tuple[Line]:
return self._rows
def get_columns(self) -> tuple[Line]:
# Transpose the rows
return tuple(zip(*self._rows))
def get_all_lines(self) -> tuple[Line]:
return self.get_rows() + self.get_columns() + self.get_diagonals()
def __repr__(self) -> str:
return self.__str__()
def __str__(self) -> str:
representation = []
COL_SEPARATOR = "|"
for row in self._rows:
representation.append(
COL_SEPARATOR.join(cell.content.value for cell in row)
)
ROW_SEPARATOR = "+".join("-" for _ in range(self.dim))
return f"\n{ROW_SEPARATOR}\n".join(representation)
def __getitem__(self, coordinate: Coordinate) -> Cell:
return self._rows[coordinate.y][coordinate.x]
@property
def dim(self) -> int:
return len(self._rows)
def is_full(self) -> bool:
return all(
not self.is_cell_empty(Coordinate(x, y))
for y in range(self.dim)
for x in range(self.dim)
)
class Player(Protocol):
name: str
def make_move(self, grid: Grid) -> Coordinate:
...
class HumanPlayer(Player):
def __init__(self, name: str, input_func: Callable[[str], str] = input) -> None:
self.name = name
self._input_func = input_func
def make_move(self, grid: Grid) -> Coordinate:
return self._get_input()
def _get_input(self) -> Coordinate:
# We only care about retrieving two valid numbers
# The rest should be validated by the Game itself
while True:
user_input = self._input_func("x y: ")
try:
mo = re.match(r"(\d+)\s+(\d+)", user_input)
if mo:
x, y = mo.groups()
return Coordinate(int(x), int(y))
except Exception:
...
print("Invalid input, try again")
class Game:
@dataclass(frozen=True)
class PlayerWrapper: # Keep implementation detail of the game here
player: Player
mark: CellContent
player_pos: int
def __init__(
self,
dimension: int,
players: tuple[Player, Player],
zero_index_mode=False,
invert_y_axis=True,
) -> None:
assert dimension > 0
self._dimension = dimension
self._zero_index_mode = zero_index_mode
self._invert_y_axis = invert_y_axis
self._grid = Grid.init_empty(dimension)
marks = [CellContent.CROSS, CellContent.CIRCLE]
random.shuffle(marks) # Assign marks randomly
self._players = tuple(
Game.PlayerWrapper(player, mark, i)
for i, (player, mark) in enumerate(zip(players, marks))
)
self._history: list[Grid] = [self._grid]
def history(self) -> tuple[Grid]:
return tuple(self._history)
def play(self) -> None:
if self._is_game_over():
print("Game is already over")
return
print("Welcome to a round of Tic Tac Toe!\n")
upper_left_coordinate = self._translate_back_coordinate(Coordinate(0, 0))
print(f"Info: The upper left corner is {upper_left_coordinate}")
current_player = random.choice(self._players)
self.print_grid()
while not self._is_game_over():
coordinate = self._get_move(current_player)
self._grid = self._grid.insert(Cell(coordinate, current_player.mark))
self.print_grid()
self._history.append(self._grid)
current_player = self._determine_next_player(current_player)
self._determine_result()
def congratulate_winner(self, winner: PlayerWrapper) -> None:
print(f"Congratulations, {winner.player.name} won!")
def determine_winner(self, line: Line) -> PlayerWrapper:
for player in self._players:
if player.mark == line[0].content:
return player
def print_grid(self) -> None:
print("\n" + str(self._grid) + "\n")
def _determine_result(self) -> None:
winning_line = self._find_winning_line()
if winning_line is None:
print("It's a draw!")
return
winner = self.determine_winner(winning_line)
self.congratulate_winner(winner)
def _is_game_over(self) -> bool:
return self._find_winning_line() is not None or self._grid.is_full()
def _translate_coordinate(self, coordinate: Coordinate) -> Coordinate:
if not self._zero_index_mode:
coordinate = Coordinate(coordinate.x - 1, coordinate.y - 1)
if self._invert_y_axis:
coordinate = Coordinate(coordinate.x, self._dimension - 1 - coordinate.y)
return coordinate
def _translate_back_coordinate(self, coordinate: Coordinate) -> Coordinate:
if self._invert_y_axis:
coordinate = Coordinate(coordinate.x, self._dimension - 1 - coordinate.y)
if not self._zero_index_mode:
coordinate = Coordinate(coordinate.x + 1, coordinate.y + 1)
return coordinate
def _get_move(self, wrapper: PlayerWrapper) -> Coordinate:
print(
f"\nIt's your turn, {wrapper.player.name} ({wrapper.mark.value}):",
)
while True:
coordinate = self._translate_coordinate( # Depends on zero_index_mode
wrapper.player.make_move(self._grid)
)
if self._check_move_valid(coordinate):
break
print("Forbidden move, try again")
return coordinate
def _check_move_valid(self, coordinate: Coordinate) -> bool:
if not self._grid.is_coordinate_within(coordinate):
# important to check this first, otherwise checking if a cell is empty
# will fail.
return False
if not self._grid.is_cell_empty(coordinate):
return False
return True
def _determine_next_player(self, current_player: PlayerWrapper) -> PlayerWrapper:
# Cycle through the players
return self._players[(current_player.player_pos + 1) % len(self._players)]
def _find_winning_line(self) -> Line:
for line in self._grid.get_all_lines():
if self._is_line_complete(line) and self._is_line_equal(line):
return line
return None
def _is_line_complete(self, line: Line) -> bool:
# all cells are non-empty
return all(cell.content != CellContent.EMPTY for cell in line)
def _is_line_equal(self, line: Line) -> bool:
# all cells have the same content if only one type is found per line
return len(set(cell.content for cell in line)) == 1
if __name__ == "__main__":
game = Game(
3,
(HumanPlayer("A"), HumanPlayer("B")),
zero_index_mode=False,
invert_y_axis=True,
)
game.play()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment