Source code for draughts.boards.base

from __future__ import annotations

import re
from abc import ABC, abstractproperty
from typing import Generator, Literal

import numpy as np

from draughts.models import FIGURE_REPR, Color, Figure, SquareT
from draughts.move import Move
from draughts.utils import (
    logger,
    get_diagonal_moves,
    get_short_diagonal_moves,
)

# fmt: off
SQUARES = [_, B8, D8, F8, H8,
        A7, C7, E7, G7,
        B6, D6, F6, H6,
        A5, C5, E5, G5,
        B4, D4, F4, H4,
        A3, C3, E3, G3,
        B2, D2, F2, H2,
        A1, C1, E1, G1] = range(33)
# fmt: on


[docs] class BaseBoard(ABC): """ Abstact class for all draughts variants. .. important:: All boards contain all methods from this class. Class is designed to support draughts boards of any size. By specifying the starting position, the user can create a board of any size. To create new variants of draughts, inherit from this class and: - override the ``legal_moves`` property - (optional) override the ``SQUARES`` list to match the new board size if you want to use UCI notation: ``[A1, B1, C1, ...]`` - override the ``STARTING_POSITION`` to specify the starting position - override the ``STARTING_COLOR`` to specify the starting color Constraints: - There are only two colors: - ``Color.WHITE`` - ``Color.BLACK`` - There are only two types of pieces: - ``PieceType.MAN`` - ``PieceType.KING`` - **Board should always be square.** .. note:: For generating legal moves use """ halfmove_clock: int = 0 """The number of half-moves since the last capture or pawn move.""" GAME_TYPE = -1 """ PDN game type. See `PDN specification <https://en.wikipedia.org/wiki/Portable_Draughts_Notation>`_. """ VARIANT_NAME = "Abstract variant" STARTING_POSITION = np.array([1] * 12 + [0] * 8 + [-1] * 12, dtype=np.int8) ROW_IDX = ... """ Dictionary of row indexes for every square. Generated only on module import. Used to calculate legal moves. """ COL_IDX = ... """ Same as ``ROW_IDX`` but for columns. """ STARTING_COLOR = Color.WHITE """ Starting color. ``Color.WHITE`` or ``Color.BLACK``. """ DIAGONAL_LONG_MOVES = ... """ Dictionary of pseudo-legal moves for king pieces. Generated only on module import. This dictionary contains all possible moves for king piece (as if there were no other pieces on the board). **Structure:** ``[(right-up moves), (left-up moves), (right-down moves), (left-down moves)]`` """ DIAGONAL_SHORT_MOVES = ... """ Same as ``DIAGONAL_LONG_MOVES`` but contains only first 2 squares of the move. (one for move and one for capture) """ def __init_subclass__(cls, **kwargs): parent_class = cls.__bases__[0] parent_class_vars = vars(parent_class) child_class_vars = vars(cls) for var_name, var_value in child_class_vars.items(): if var_name in parent_class_vars and not var_name.startswith("_"): setattr(parent_class, var_name, var_value) cls.DIAGONAL_SHORT_MOVES = get_diagonal_moves(len(cls.STARTING_POSITION)) cls.DIAGONAL_LONG_MOVES = get_short_diagonal_moves(len(cls.STARTING_POSITION)) def __init__( self, starting_position: np.ndarray = None, turn: Color = None, ) -> None: """ Initializes the board with a starting position. The starting position must be a numpy array of length n * n/2, where n is the size of the board. """ super().__init__() self._pos = ( starting_position if starting_position is not None else self.STARTING_POSITION.copy() ) self.turn = turn if turn is not None else self.STARTING_COLOR size = int(np.sqrt(len(self._pos) * 2)) if size**2 != len(self._pos) * 2: msg = f"Invalid board with shape {self._pos.shape} provided.\ Please use an array with lenght = (n * n/2). \ Where n is an size of the board." logger.error(msg) raise ValueError(msg) self.shape = (size, size) self._moves_stack: list[Move] = [] logger.info(f"Board initialized with shape {self.shape}.") # @abstractmethod @abstractproperty def legal_moves(self) -> Generator[Move, None, None]: """ Return list legal moves for the current position. *For every concrete variant of draughts this method should be overriden.* .. warning:: Depending of implementation method can return generator or list. """ pass @property def position(self) -> np.ndarray: """Returns board position.""" return self._pos @property def is_threefold_repetition(self) -> bool: if len(self._moves_stack) >= 9: if ( self._moves_stack[-1].square_list == self._moves_stack[-5].square_list == self._moves_stack[-9].square_list ): return True return False @abstractproperty def is_draw(self) -> bool: ... @property def game_over(self) -> bool: """Returns ``True`` if the game is over.""" # check if threefold repetition return self.is_draw or not bool(list(self.legal_moves))
[docs] def push(self, move: Move, is_finished: bool = True) -> None: """Pushes a move to the board. Automatically promotes a piece if it reaches the last row. If ``is_finished`` is set to ``True``, the turn is switched. This parameter is used only for generating legal moves. """ move.halfmove_clock = self.halfmove_clock # Before move occurs src, tg = ( move.square_list[0], move.square_list[-1], ) self._pos[src], self._pos[tg] = self._pos[tg], self._pos[src] # is promotion if ( (tg // (self.shape[0] // 2)) == 0 and self._pos[tg] == Figure.WHITE_MAN.value and is_finished ) or ( (tg // (self.shape[0] // 2)) == (self.shape[0] - 1) and self._pos[tg] == Figure.BLACK_MAN.value and is_finished ): self._pos[tg] *= Figure.KING.value move.is_promotion = True elif ( abs(self._pos[tg]) == Figure.KING.value and not move.captured_list and is_finished ): self.halfmove_clock += 1 elif is_finished: self.halfmove_clock = 0 if move.captured_list: self._pos[ np.array([sq for sq in move.captured_list if sq != tg]) ] = Figure.EMPTY self._moves_stack.append(move) if is_finished: self.turn = Color.WHITE if self.turn == Color.BLACK else Color.BLACK
[docs] def pop(self, is_finished=True) -> None: """Pops a move from the board. If ``is_finished`` is set to ``True``, the turn is switched. This parameter is used only for generating legal moves. """ move = self._moves_stack.pop() src, tg = ( move.square_list[0], move.square_list[-1], ) if move.is_promotion: self._pos[tg] //= Figure.KING.value self._pos[src], self._pos[tg] = self._pos[tg], self._pos[src] self.halfmove_clock = move.halfmove_clock for sq, entity in zip(move.captured_list, move.captured_entities): self._pos[sq] = entity # Dangerous line if is_finished: self.turn = Color.WHITE if self.turn == Color.BLACK else Color.BLACK return move
[docs] def push_uci(self, str_move: str) -> None: """ Allows to push a move from a string. * Converts string to ``Move`` object * calls ``BaseBoard.push`` method """ try: move = Move.from_uci(str_move, self.legal_moves) except ValueError as e: logger.error(f"{e} \n {str(self)}") raise e self.push(move)
@property def fen(self): """ Returns a FEN string of the board position. ``[FEN "[Turn]:[Color 1][K][Square number][,]...]:[Color 2][K][Square number][,]...]"]`` Fen examples: - ``[FEN "B:W18,24,27,28,K10,K15:B12,16,20,K22,K25,K29"]`` - ``[FEN "B:W18,19,21,23,24,26,29,30,31,32:B1,2,3,4,6,7,9,10,11,12"]`` """ COLORS_REPR = {Color.WHITE: "W", Color.BLACK: "B"} fen_components = [ f'[FEN "W:{COLORS_REPR[self.turn]}:W', ",".join( "K" * bool(self._pos[sq] < -1) + str(sq + 1) for sq in np.where(self.position < 0)[0] ), ":B", ",".join( "K" * bool(self._pos[sq] > 1) + str(sq + 1) for sq in np.where(self.position > 0)[0] ), '"]', ] return "".join(fen_components)
[docs] @classmethod def from_fen(cls, fen: str) -> BaseBoard: """ Creates a board from a FEN string by using regular expressions. """ logger.debug(f"Initializing board from FEN: {fen}") fen = fen.upper() re_turn = re.compile(r"[WB]:") re_premove = re.compile(r"(G[0-9]+|P[0-9]+)(,|)") re_prefix = re.compile(r"[WB]:[WB]:[WB]") re_white = re.compile(r"W[0-9K,]+") re_black = re.compile(r"B[0-9K,]+") # remove premoves from fen # remove first 2 letters from prefix fen = re_premove.sub("", fen) prefix = re_prefix.search(fen) if prefix: prefix = prefix.group(0) fen = fen.replace(prefix, prefix[2:]) try: turn = re_turn.search(fen).group(0)[0] white = re_white.search(fen).group(0).replace("W", "") black = re_black.search(fen).group(0).replace("B", "") except AttributeError as e: raise AttributeError(f"Invalid FEN: {fen} \n {e}") logger.debug(f"turn: {turn}, white: {white}, black: {black}") cls.STARTING_POSITION = np.zeros(cls.STARTING_POSITION.shape, dtype=np.int8) if len(turn) != 1 or (len(white) == 0 and len(black) == 0): raise ValueError(f"Invalid FEN: {fen}") try: cls.__populate_from_list(white.split(","), Color.WHITE) cls.__populate_from_list(black.split(","), Color.BLACK) except ValueError as e: logger.error(f"Invalid FEN: {fen} \n {e}") turn = Color.WHITE if turn == "W" else Color.BLACK return cls(cls.STARTING_POSITION, turn)
@classmethod def __populate_from_list(cls, fen_list: list[str], color: Color) -> None: board_range = range(1, cls.STARTING_POSITION.shape[0] + 1) for sq in fen_list: if sq.isdigit() and int(sq) in board_range: cls.STARTING_POSITION[int(sq) - 1] = color.value elif sq.startswith("K") and sq[1:].isdigit() and int(sq[1:]) in board_range: cls.STARTING_POSITION[int(sq[1:]) - 1] = color.value * Figure.KING.value else: raise ValueError( f"invalid square value: {sq} for board with length\ {cls.STARTING_POSITION.shape[0]}" ) @property def result(self) -> Literal["1/2-1/2", "1-0", "0-1", "-"]: """ Returns a result of the game. """ if self.is_draw: return "1/2-1/2" if self.turn == Color.WHITE and self.game_over: return "0-1" if self.turn == Color.BLACK and self.game_over: return "1-0" return "-" @property def friendly_form(self) -> np.ndarray: """ Returns a board position in a friendly form. *Makes board with size n x n from a board with size n x n/2* """ new_pos = [0] for idx, sq in enumerate(self.position): new_pos.extend([0] * (idx % (self.shape[0] // 2) != 0)) new_pos.extend([0, 0] * (idx % self.shape[0] == 0 and idx != 0)) new_pos.append(sq) new_pos.append(0) return np.array(new_pos) @property def pdn(self) -> str: """ Returns a PDN string that represents the game. pdn - Portable Draughts Notation Example: ``` [GameType "20"] [Variant "Standard (international) checkers"] [Result "-"] 1. 34-29 17-21 2. 33-28 12-17 3. 38-33 21-26 4. 29-24 20x27 5. 31x22 18x27 ``` """ data = ( f'[GameType "{self.GAME_TYPE}"]\n' f'[Variant "{self.VARIANT_NAME}"]\n' f'[Result "{self.result}"]\n' ) history = [] # (number, white, black) for idx, move in enumerate(self._moves_stack): if idx % 2 == 0: history.append([(idx // 2) + 1, str(move)]) else: history[-1].append(str(move)) return ( data + " ".join(f"{h[0]}. {' '.join(h[1:])}" for h in history) + self.result * (len(self.result) - 1) )
[docs] @staticmethod def is_capture(move: Move) -> bool: """ Checks if a move is a capture. """ return len(move.captured_list) > 0
def __repr__(self) -> str: board = "" position = self.friendly_form for i in range(self.shape[0]): # board += f"{'-' * (self.shape[0]*4 + 1) }\n|" for j in range(self.shape[0]): board += f" {FIGURE_REPR[position[i*self.shape[0] + j]]}" board += "\n" return board def __iter__(self) -> Generator[Figure, None, None]: for sq in self.position: yield sq def __getitem__(self, key: SquareT) -> Figure: return self.position[key]