"""The Tic-tac-Toe game."""
from __future__ import annotations
from enum import IntEnum
from typing import Callable, ClassVar, Final, Generator, Literal, NewType, Union, cast
__all__ = [
"Board",
"BoardPosition",
"Coordinate",
"MoveEvaluation",
"Player",
"Transformation",
"XCoord",
"YCoord",
]
BoardPosition = NewType("BoardPosition", int)
XCoord = Literal[0, 1, 2, 3]
YCoord = Literal[0, 1, 2, 3]
Coordinate = tuple[XCoord, YCoord]
"""The type for a co-ordinate."""
MoveEvaluation = Union[
tuple[int, XCoord, YCoord],
tuple[int, Literal[-1], Literal[-1]],
]
"""The type for the evaluation of a move"""
class Player(IntEnum):
"""Enumeration of players within a grid cell."""
NONE = 0b00
X = 0b01
O = 0b10 # noqa E741
@property
def opponent(self) -> Player:
"""The player's opponent."""
return {
Player.NONE: Player.NONE,
Player.X: Player.O,
Player.O: Player.X,
}[self]
def _identity(position: BoardPosition) -> BoardPosition:
"""No transformation.
Args:
position: The positions taken by players on the grid.
Returns:
The position after applying the transformation.
"""
return position
def _reflect_x(position: BoardPosition) -> BoardPosition:
"""Reflect the grid horizontally.
Args:
position: The positions taken by players on the grid.
Returns:
The position after applying the transformation.
"""
return cast(
"BoardPosition",
((position & 0b11000000110000001100000011000000) >> 6)
| ((position & 0b00110000001100000011000000110000) >> 2)
| ((position & 0b00001100000011000000110000001100) << 2)
| ((position & 0b00000011000000110000001100000011) << 6),
)
def _reflect_y(position: BoardPosition) -> BoardPosition:
"""Reflect the grid vertically.
Args:
position: The positions taken by players on the grid.
Returns:
The position after applying the transformation.
"""
return cast(
"BoardPosition",
((position & 0b11111111000000000000000000000000) >> 24)
| ((position & 0b00000000111111110000000000000000) >> 8)
| ((position & 0b00000000000000001111111100000000) << 8)
| ((position & 0b00000000000000000000000011111111) << 24),
)
def _reflect_x_y(position: BoardPosition) -> BoardPosition:
"""Reflect the grid horizontally and then vertically.
Args:
position: The positions taken by players on the grid.
Returns:
The position after applying the transformation.
"""
return _reflect_y(_reflect_x(position))
def _reflect_xy(position: BoardPosition) -> BoardPosition:
"""Reflect the grid in the x=y diagonal.
Args:
position: The positions taken by players on the grid.
Returns:
The position after applying the transformation.
"""
return cast(
"BoardPosition",
(position & 0b11000000001100000000110000000011)
| ((position & 0b00110000000011000000001100000000) >> 6)
| ((position & 0b00000000110000000011000000001100) << 6)
| ((position & 0b00001100000000110000000000000000) >> 12)
| ((position & 0b00000000000000001100000000110000) << 12)
| ((position & 0b00000011000000000000000000000000) >> 18)
| ((position & 0b00000000000000000000000011000000) << 18),
)
def _reflect_xny(position: BoardPosition) -> BoardPosition:
"""Reflect the grid in the x=-y diagonal.
Args:
position: The positions taken by players on the grid.
Returns:
The position after applying the transformation.
"""
return _reflect_x_y(_reflect_xy(position))
def _rotate_clockwise(position: BoardPosition) -> BoardPosition:
"""Rotate 90° clockwise about the centre of the grid.
Args:
position: The positions taken by players on the grid.
Returns:
The position after applying the transformation.
"""
return _reflect_x(_reflect_xy(position))
def _rotate_anticlockwise(position: BoardPosition) -> BoardPosition:
"""Rotate 90° anti-clockwise about the centre of the grid.
Args:
position: The positions taken by players on the grid.
Returns:
The position after applying the transformation.
"""
return _reflect_y(_reflect_xy(position))
class Transformation(IntEnum):
"""Enumeration of reflections and rotations relative to the centre of the grid."""
NONE = 0, ((+1, +0), (+0, +1)), _identity
REFLECT_X = 1, ((-1, +0), (+0, +1)), _reflect_x
REFLECT_Y = 2, ((+1, +0), (+0, -1)), _reflect_y
REFLECT_X_Y = 3, ((-1, +0), (+0, -1)), _reflect_x_y
REFLECT_DIAGONAL = 4, ((+0, +1), (+1, +0)), _reflect_xy
REFLECT_ANTI_DIAGONAL = 5, ((+0, -1), (-1, +0)), _reflect_xny
ROTATE_CLOCKWISE = 6, ((+0, +1), (-1, +0)), _rotate_clockwise
ROTATE_ANTICLOCKWISE = 7, ((+0, -1), (+1, +0)), _rotate_anticlockwise
def __new__(
cls,
_value: int,
_matrix: tuple[
tuple[Literal[-1, 0, 1], Literal[-1, 0, 1]],
tuple[Literal[-1, 0, 1], Literal[-1, 0, 1]],
],
_apply: Callable[[BoardPosition], BoardPosition],
) -> Transformation:
"""Create a new transformation enumeration.
Args:
_value: The integer enumeration for the transformation.
_matrix: The transformation matrix for co-ordinates.
_apply: The transformation function for a grid.
Returns:
The transformation enumeration instance.
"""
transform = int.__new__(cls, _value)
transform._value_ = _value
return transform
def __init__(
self,
_value: int,
_matrix: tuple[
tuple[Literal[-1, 0, 1], Literal[-1, 0, 1]],
tuple[Literal[-1, 0, 1], Literal[-1, 0, 1]],
],
_apply: Callable[[BoardPosition], BoardPosition],
) -> None:
"""Initialise the transformation.
Args:
_value: The integer enumeration for the transformation.
_matrix: The transformation matrix for co-ordinates.
_apply: The transformation function for a grid.
"""
super().__init__()
self.matrix: Final = _matrix
self.apply: Final = _apply
@property
def inverse(self) -> Transformation:
"""The inverse of the transformation."""
return [
Transformation.NONE,
Transformation.REFLECT_X,
Transformation.REFLECT_Y,
Transformation.REFLECT_X_Y,
Transformation.REFLECT_DIAGONAL,
Transformation.REFLECT_ANTI_DIAGONAL,
Transformation.ROTATE_ANTICLOCKWISE,
Transformation.ROTATE_CLOCKWISE,
][self]
def __mul__(self, value: object) -> Transformation:
"""Multiply two transformations to give a composite transformation.
Args:
value: The second transformation.
Returns:
The composite transformation.
"""
assert isinstance(value, Transformation)
return [
[
Transformation.NONE,
Transformation.REFLECT_X,
Transformation.REFLECT_Y,
Transformation.REFLECT_X_Y,
Transformation.REFLECT_DIAGONAL,
Transformation.REFLECT_ANTI_DIAGONAL,
Transformation.ROTATE_CLOCKWISE,
Transformation.ROTATE_ANTICLOCKWISE,
],
[
Transformation.REFLECT_X,
Transformation.NONE,
Transformation.REFLECT_X_Y,
Transformation.REFLECT_Y,
Transformation.ROTATE_ANTICLOCKWISE,
Transformation.ROTATE_CLOCKWISE,
Transformation.REFLECT_ANTI_DIAGONAL,
Transformation.REFLECT_DIAGONAL,
],
[
Transformation.REFLECT_Y,
Transformation.REFLECT_X_Y,
Transformation.NONE,
Transformation.REFLECT_X,
Transformation.ROTATE_CLOCKWISE,
Transformation.ROTATE_ANTICLOCKWISE,
Transformation.REFLECT_DIAGONAL,
Transformation.REFLECT_ANTI_DIAGONAL,
],
[
Transformation.REFLECT_X_Y,
Transformation.REFLECT_Y,
Transformation.REFLECT_X,
Transformation.NONE,
Transformation.REFLECT_ANTI_DIAGONAL,
Transformation.REFLECT_DIAGONAL,
Transformation.ROTATE_ANTICLOCKWISE,
Transformation.ROTATE_CLOCKWISE,
],
[
Transformation.REFLECT_DIAGONAL,
Transformation.ROTATE_CLOCKWISE,
Transformation.ROTATE_ANTICLOCKWISE,
Transformation.REFLECT_ANTI_DIAGONAL,
Transformation.NONE,
Transformation.REFLECT_X_Y,
Transformation.REFLECT_X,
Transformation.REFLECT_Y,
],
[
Transformation.REFLECT_ANTI_DIAGONAL,
Transformation.ROTATE_ANTICLOCKWISE,
Transformation.ROTATE_CLOCKWISE,
Transformation.REFLECT_DIAGONAL,
Transformation.REFLECT_X_Y,
Transformation.NONE,
Transformation.REFLECT_Y,
Transformation.REFLECT_X,
],
[
Transformation.ROTATE_CLOCKWISE,
Transformation.REFLECT_DIAGONAL,
Transformation.REFLECT_ANTI_DIAGONAL,
Transformation.ROTATE_ANTICLOCKWISE,
Transformation.REFLECT_Y,
Transformation.REFLECT_X,
Transformation.REFLECT_X_Y,
Transformation.NONE,
],
[
Transformation.ROTATE_ANTICLOCKWISE,
Transformation.REFLECT_ANTI_DIAGONAL,
Transformation.REFLECT_DIAGONAL,
Transformation.ROTATE_CLOCKWISE,
Transformation.REFLECT_X,
Transformation.REFLECT_Y,
Transformation.NONE,
Transformation.REFLECT_X_Y,
],
][self][value]
def coord(self, x: XCoord, y: YCoord) -> Coordinate:
"""Apply the transformation to the co-ordinate.
Args:
x: The x-value of the co-ordinate.
y: The y-value of the co-ordinate.
Returns:
The transformed co-ordinate.
"""
# (x y)(a b) = (ax+cy bx+dy)
# (c d)
xx, yy = x*2-3, y*2-3
(a, b), (c, d) = self.matrix
tx = cast("XCoord", (a*xx + c*yy + 3)//2)
ty = cast("YCoord", (b*xx + d*yy + 3)//2)
return (tx, ty)
class Board:
"""The Tic-Tac-Toe board."""
_position: BoardPosition
_transform: Transformation
_moves: int
_solutions: ClassVar[dict[Player, dict[BoardPosition, MoveEvaluation]]] = {
Player.X: {},
Player.O: {},
}
@classmethod
def from_string(cls, state: str) -> Board:
"""Create a board from a text representation of the board state.
Newline characters in the input are ignored. Any other character that is not X
or O is considered an empty grid-cell.
Args:
state: The text representation of the board state.
Returns:
The board representing that state.
"""
p = 0
ch_map = {Player.X.name: int(Player.X), Player.O.name: int(Player.O)}
count = [0, 0, 0]
for ch in reversed(state):
if ch == "\n":
continue
value = ch_map.get(ch, 0)
count[value] += 1
p = (p << 2) + value
if sum(count) != 16:
raise ValueError(f"{state} does not have 16 grid-cells.")
if count[1] < count[2]:
raise ValueError(f"{state} has too many O's.")
if count[1] > count[2] + 1:
raise ValueError(f"{state} has too many X's.")
return Board(
BoardPosition(p),
Transformation.NONE,
count[1] + count[2],
)
def __init__(
self,
positions: BoardPosition = BoardPosition(0),
transform: Transformation = Transformation.NONE,
moves: int = -1,
) -> None:
"""Initialise the board.
This will normalise the board by applying the transformation that minimises
the position value.
Args:
positions: The positions taken by players in the grid.
transform: The transformation applied to the grid.
moves: The number of moves taken by the players.
"""
pos, trans = min((t.apply(positions), t) for t in Transformation)
self._position = pos
self._transform = transform * trans
if moves < 0:
p: int = positions
counts = [16, 0, 0]
moves = 0
while p > 0:
cell = p & 0b11
if cell > 0:
counts[cell] += 1
counts[0] -= 1
moves += 1
p >>= 2
if counts[1] > counts[2] + 1:
raise ValueError(f"{positions:032b} has too many X's.")
if counts[2] > counts[1]:
raise ValueError(f"{positions:032b} has too many O's.")
self._moves = moves
@staticmethod
def _offset(x: XCoord, y: YCoord) -> int:
"""Convert the (x, y) co-ordinate into the bit-offset for the position.
Args:
x: The x co-ordinate.
y: The y co-ordinate.
Return:
The bit-offset for the cell within the positions integer.
"""
return 2*x + 8*y
def cell(
self,
x: XCoord,
y: YCoord,
) -> Player:
"""Return the player occupying the grid-cell at the given co-ordinate.
Args:
x: The x co-ordinate of the grid-cell.
y: The y co-ordinate of the grid-cell.
Return:
The Player occupying that grid-cell.
"""
xx, yy = self._transform.coord(x, y)
return Player((self._position >> Board._offset(xx, yy)) & 0b11)
def _cache_solution(
self,
player: Player,
result: MoveEvaluation,
) -> None:
score, x, y = result
if x == -1 or y == -1:
Board._solutions[player][self._position] = result
else:
xx, yy = self._transform.coord(x, y)
Board._solutions[player][self._position] = (score, xx, yy)
def _get_cached_solution(
self,
player: Player,
) -> MoveEvaluation:
result = Board._solutions[player][self._position]
score, x, y = result
if x == -1 or y == -1:
return result
xx, yy = self._transform.inverse.coord(x, y)
return (score, xx, yy)
@property
def possible_moves(self) -> Generator[Coordinate, None, None]:
"""The remaining possible moves for the current board state."""
p: int = self._transform.inverse.apply(self._position)
for y in (0, 1, 2, 3):
for x in (0, 1, 2, 3):
if (p & 0b11) == 0:
yield (x, y)
p >>= 2
@property
def current_player(self) -> Player:
"""The player taking the current move."""
return Player((self._moves % 2) + 1)
def move(self, x: XCoord, y: YCoord) -> Board:
"""Make a move for the current player on the board.
Args:
x: The x co-ordinate of the move.
y: The x co-ordinate of the move.
Returns:
A :class:`Board` instance representing the state after the move.
"""
xx, yy = self._transform.coord(x, y)
assert self.cell(x, y) == Player.NONE, (
f"({x}, {y}) {self._transform.name} => ({xx}, {yy}) is not none "
f"({self._position:032b})"
)
return Board(
positions=BoardPosition(
self._position | self.current_player << Board._offset(xx, yy),
),
transform=self._transform,
moves=self._moves + 1,
)
@property
def winner(self) -> Player:
"""The :class:`Player` who has won the board.
Returns:
The :class:`Player` who has won the board.
"""
if self._moves < 5:
return Player.NONE
for mask_x, shifts in (
(0b01_00000001_00000001, (2, 2, 2, 2, 2, 2, 2, 2)), # Vertical lines
(0b010101, (2, 6, 2, 6, 2, 6, 2, 6)), # Horizontal lines
(0b010000_00000100_00000001, (2, 6, 2, 6)), # y=x Diagonal lines
(0b000001_00000100_00010000, (2, 6, 2, 6)), # y=-x Diagonal lines
):
p: int = self._position
for shift in shifts:
if p < mask_x:
break
if (p & mask_x) == mask_x:
return Player.X if self._moves >= 15 else Player.O
mask_o = mask_x << 1
if (p & mask_o) == mask_o:
return Player.O if self._moves >= 15 else Player.X
p >>= shift
return Player.NONE
def optimal_move(
self,
maximising_player: Player,
) -> MoveEvaluation:
"""Find the optimal move for the current board state.
Returns:
A ``tuple`` containing:
- the score for the move;
- the x co-ordinate of the optimal move or ``-1`` for a draw; and
- the y co-ordinate of the optimal move or ``-1`` for a draw.
"""
if self._position in Board._solutions[maximising_player]:
return self._get_cached_solution(maximising_player)
winner = self.winner
result: MoveEvaluation
if winner != Player.NONE:
result = (
(17 - self._moves)
if winner == maximising_player
else (self._moves - 17),
-1,
-1,
)
Board._cache_solution(self, maximising_player, result)
return result
if self._moves >= 16:
result = (0, -1, -1)
Board._cache_solution(self, maximising_player, result)
return result
compare = max if maximising_player == self.current_player else min
result = compare(
(self.move(x, y).optimal_move(maximising_player)[0], x, y)
for x, y in self.possible_moves
)
Board._cache_solution(self, maximising_player, result)
return result
def __str__(self) -> str:
"""Return the board state.
Returns:
The board state.
"""
p: int = self._transform.inverse.apply(self._position)
def cell_value() -> str:
nonlocal p
c = {
int(Player.NONE): "_",
int(Player.X): "X",
int(Player.O): "O",
}[p & 0b11]
p >>= 2
return c
return "\n".join("".join(cell_value() for x in range(4)) for y in range(4))
if __name__ == "__main__":
board = Board()
print(f"\n{board}")
while not board.winner:
score, x, y = board.optimal_move(board.current_player)
if x == -1 or y == -1:
break
board = board.move(x, y)
print(f"\n{board} ({x}, {y}): {score}")