Source code for tictactoe_policy.game
"""Board configuration and rules for configurable Tic-Tac-Toe."""
from dataclasses import dataclass
from typing import Iterable, Sequence
#: Smallest supported board width and height.
MIN_BOARD_SIZE = 3
#: Largest supported board width and height.
MAX_BOARD_SIZE = 8
[docs]
@dataclass(frozen=True)
class GameConfig:
"""Immutable game dimensions.
:param board_size: Board width and height, from 3 through 8.
:param win_length: Consecutive marks required to win. Defaults to the board
size when omitted.
:raises ValueError: If either dimension is outside its valid range.
:ivar board_size: Configured board width and height.
:ivar win_length: Configured number of consecutive marks needed to win.
"""
#: Board width and height.
board_size: int = 3
#: Consecutive marks required to win.
win_length: int | None = None
def __post_init__(self) -> None:
if not MIN_BOARD_SIZE <= self.board_size <= MAX_BOARD_SIZE:
raise ValueError(
f"board_size must be between {MIN_BOARD_SIZE} and {MAX_BOARD_SIZE}"
)
if self.win_length is None:
object.__setattr__(self, "win_length", self.board_size)
if not 1 <= self.win_length <= self.board_size:
raise ValueError("win_length must be between 1 and board_size")
@property
def cell_count(self) -> int:
"""Return the total number of board cells.
:returns: ``board_size`` squared.
"""
return self.board_size * self.board_size
[docs]
class TicTacToeGame:
"""Validate boards and implement configurable Tic-Tac-Toe rules.
:param config: Game dimensions. Standard 3x3 rules are used when omitted.
:ivar config: Dimensions used by this game.
:ivar lines: Every winning line expressed as row-major cell indices.
"""
def __init__(self, config: GameConfig | None = None) -> None:
self.config = config or GameConfig()
self.lines = tuple(self._winning_lines())
def _winning_lines(self) -> Iterable[tuple[int, ...]]:
size = self.config.board_size
length = self.config.win_length
directions = ((0, 1), (1, 0), (1, 1), (1, -1))
for row in range(size):
for column in range(size):
for delta_row, delta_column in directions:
end_row = row + (length - 1) * delta_row
end_column = column + (length - 1) * delta_column
if 0 <= end_row < size and 0 <= end_column < size:
yield tuple(
(row + step * delta_row) * size
+ column
+ step * delta_column
for step in range(length)
)
[docs]
def validate_board(
self, board: Sequence[int], check_reachable: bool = True
) -> tuple[int, ...]:
"""Validate a board and return an immutable representation.
:param board: Board cells in row-major order, using ``1`` for X,
``-1`` for O, and ``0`` for an empty cell.
:param check_reachable: Validate turn counts and winner consistency.
:returns: The validated board as a tuple.
:raises ValueError: If the dimensions, cells, turn counts, or winners
are invalid.
"""
if len(board) != self.config.cell_count:
raise ValueError(
f"board must contain {self.config.cell_count} cells, got {len(board)}"
)
result = tuple(board)
if any(cell not in (-1, 0, 1) for cell in result):
raise ValueError("board cells must be -1, 0, or 1")
if check_reachable:
x_count = result.count(1)
o_count = result.count(-1)
if x_count not in (o_count, o_count + 1):
raise ValueError("board has an invalid number of X and O marks")
winners = self.winners(result)
if len(winners) > 1:
raise ValueError("board cannot contain wins for both players")
if winners == {1} and x_count != o_count + 1:
raise ValueError("X win has an invalid turn count")
if winners == {-1} and x_count != o_count:
raise ValueError("O win has an invalid turn count")
return result
[docs]
def winners(self, board: Sequence[int]) -> set[int]:
"""Find every player with a completed winning line.
:param board: Board cells in row-major order.
:returns: Set containing ``1`` and/or ``-1`` for winning players.
"""
return {
board[line[0]]
for line in self.lines
if board[line[0]] != 0
and all(board[index] == board[line[0]] for index in line)
}
[docs]
def winner(self, board: Sequence[int]) -> int:
"""Return the sole winner of a board.
:param board: Board cells in row-major order.
:returns: ``1`` for X, ``-1`` for O, or ``0`` when there is no sole
winner.
"""
winners = self.winners(board)
return next(iter(winners)) if len(winners) == 1 else 0
[docs]
def legal_moves(self, board: Sequence[int]) -> list[int]:
"""List the unoccupied cells on a board.
:param board: Board cells in row-major order.
:returns: Legal move indices in ascending order.
"""
return [index for index, cell in enumerate(board) if cell == 0]
[docs]
def is_terminal(self, board: Sequence[int]) -> bool:
"""Determine whether a board is won or full.
:param board: Board cells in row-major order.
:returns: True when play cannot continue.
"""
return bool(self.winners(board)) or 0 not in board
[docs]
def infer_player(self, board: Sequence[int]) -> int:
"""Infer whose turn follows an X-first reachable board.
:param board: Non-terminal board cells in row-major order.
:returns: ``1`` when X moves next or ``-1`` when O moves next.
:raises ValueError: If the board is invalid or terminal.
"""
valid = self.validate_board(board)
if self.is_terminal(valid):
raise ValueError("cannot infer a player for a terminal board")
return 1 if valid.count(1) == valid.count(-1) else -1
[docs]
@staticmethod
def apply_move(board: Sequence[int], move: int, player: int) -> tuple[int, ...]:
"""Apply one legal move without modifying the input board.
:param board: Board cells in row-major order.
:param move: Index of the empty cell to occupy.
:param player: Player mark, either ``1`` or ``-1``.
:returns: A new immutable board containing the move.
:raises ValueError: If the player or move is invalid.
"""
if player not in (-1, 1):
raise ValueError("player must be -1 or 1")
if move < 0 or move >= len(board) or board[move] != 0:
raise ValueError("move must identify an empty cell")
result = list(board)
result[move] = player
return tuple(result)