Source code for draughts.boards.base

"""Abstract base class for draughts boards using bitboard representation."""
from __future__ import annotations

import copy
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Generator, Literal, Optional

import numpy as np
from loguru import logger

from draughts.models import Color, Figure, FIGURE_REPR
from draughts.move import Move


[docs] @dataclass(frozen=True, slots=True) class BoardFeatures: """ Extracted features from a board position for AI/ML use. All counts and metrics are computed on-demand and returned as immutable data. This does not store any references to the board. Attributes: white_men: Number of white men on the board. white_kings: Number of white kings on the board. black_men: Number of black men on the board. black_kings: Number of black kings on the board. turn: 1 if white to move, -1 if black to move. mobility: Number of legal moves for the side to move. material_balance: (white_men + 2*white_kings) - (black_men + 2*black_kings). phase: Game phase: 'opening', 'midgame', or 'endgame'. """ white_men: int white_kings: int black_men: int black_kings: int turn: int mobility: int material_balance: float phase: str
[docs] class BaseBoard(ABC): """ Abstract base class for all draughts board variants. Uses bitboard representation for efficient move generation. Board state is stored as four integers: ``white_men``, ``white_kings``, ``black_men``, ``black_kings``. Attributes: turn: Current side to move (:class:`Color.WHITE` or :class:`Color.BLACK`). halfmove_clock: Moves since last capture or man move (for draw detection). shape: Board dimensions as tuple, e.g. ``(10, 10)`` for standard. Example: >>> from draughts import Board >>> board = Board() >>> board.push_uci("31-27") >>> print(board.turn) Color.BLACK """ GAME_TYPE: int = -1 VARIANT_NAME: str = "Abstract" STARTING_COLOR: Color = Color.WHITE SQUARES_COUNT: int = 50 PROMO_WHITE: int = 0 PROMO_BLACK: int = 0 ROW_IDX: dict = {} COL_IDX: dict = {} STARTING_POSITION: np.ndarray = np.array([], dtype=np.int8) SQUARE_NAMES: list[str] = [] __slots__ = ('white_men', 'white_kings', 'black_men', 'black_kings', 'turn', 'halfmove_clock', '_moves_stack', 'shape') def __init__(self, starting_position: Optional[np.ndarray] = None, turn: Optional[Color] = None) -> None: """ Initialize a new board. Args: starting_position: Optional numpy array with piece positions. Values: 1=black man, 2=black king, -1=white man, -2=white king, 0=empty. If None, uses the standard starting position for the variant. turn: Side to move first. Defaults to ``Color.WHITE``. Example: >>> board = Board() # Standard starting position >>> board = Board.from_fen("W:WK10:BK35") # Custom position """ size = int(np.sqrt(self.SQUARES_COUNT * 2)) self.shape = (size, size) self.turn = turn if turn is not None else self.STARTING_COLOR self.halfmove_clock = 0 self._moves_stack: list[Move] = [] if starting_position is not None: self._from_array(starting_position) else: self._init_default_position() logger.info(f"Board initialized with shape {self.shape}.") @abstractmethod def _init_default_position(self) -> None: """Set bitboards to starting position.""" pass def _from_array(self, arr: np.ndarray) -> None: """Load position from numpy array (1=BM, 2=BK, -1=WM, -2=WK).""" self.white_men = self.white_kings = self.black_men = self.black_kings = 0 for sq, val in enumerate(arr): bit = 1 << sq if val == 1: self.black_men |= bit elif val == 2: self.black_kings |= bit elif val == -1: self.white_men |= bit elif val == -2: self.white_kings |= bit def _all(self) -> int: return self.white_men | self.white_kings | self.black_men | self.black_kings def _empty(self) -> int: return ~self._all() & ((1 << self.SQUARES_COUNT) - 1) def _enemy(self) -> int: return (self.black_men | self.black_kings) if self.turn == Color.WHITE else (self.white_men | self.white_kings) def _get(self, sq: int) -> int: """Get piece at square: -2=WK, -1=WM, 0=empty, 1=BM, 2=BK.""" bit = 1 << sq if self.white_men & bit: return -1 if self.white_kings & bit: return -2 if self.black_men & bit: return 1 if self.black_kings & bit: return 2 return 0 def _set(self, sq: int, piece: int) -> None: """Set piece at square.""" bit, inv = 1 << sq, ~(1 << sq) self.white_men &= inv; self.white_kings &= inv self.black_men &= inv; self.black_kings &= inv if piece == -1: self.white_men |= bit elif piece == -2: self.white_kings |= bit elif piece == 1: self.black_men |= bit elif piece == 2: self.black_kings |= bit @staticmethod def _popcount(bb: int) -> int: return bin(bb).count('1') @property @abstractmethod def legal_moves(self) -> list[Move]: """ All legal moves for the current player. Returns: List of :class:`Move` objects representing all legal moves. Example: >>> board = Board() >>> moves = board.legal_moves >>> print(len(moves)) # 9 moves in starting position 9 """ pass @property @abstractmethod def is_draw(self) -> bool: """ Check if the current position is a draw. Draw conditions vary by variant (e.g., 25-move rule, threefold repetition). Returns: True if the position is drawn, False otherwise. """ pass
[docs] def push(self, move: Move, is_finished: bool = True) -> None: """ Apply a move to the board. Args: move: The :class:`Move` to apply. is_finished: If True, switches turn after the move. Set to False during internal move generation. Example: >>> board = Board() >>> move = board.legal_moves[0] >>> board.push(move) """ move.halfmove_clock = self.halfmove_clock src, tgt = move.square_list[0], move.square_list[-1] piece = self._get(src) src_bit, tgt_bit = 1 << src, 1 << tgt # Move piece if piece == -1: self.white_men = (self.white_men & ~src_bit) | tgt_bit elif piece == -2: self.white_kings = (self.white_kings & ~src_bit) | tgt_bit elif piece == 1: self.black_men = (self.black_men & ~src_bit) | tgt_bit else: self.black_kings = (self.black_kings & ~src_bit) | tgt_bit if is_finished: # Promotion if piece == -1 and (self.PROMO_WHITE & tgt_bit): self.white_men &= ~tgt_bit; self.white_kings |= tgt_bit; move.is_promotion = True elif piece == 1 and (self.PROMO_BLACK & tgt_bit): self.black_men &= ~tgt_bit; self.black_kings |= tgt_bit; move.is_promotion = True # Halfmove clock elif abs(piece) == 2 and not move.captured_list: self.halfmove_clock += 1 else: self.halfmove_clock = 0 # Remove captures for cap_sq in move.captured_list: if cap_sq != tgt: bit = ~(1 << cap_sq) self.white_men &= bit; self.white_kings &= bit self.black_men &= bit; self.black_kings &= bit self._moves_stack.append(move) if is_finished: self.turn = Color.BLACK if self.turn == Color.WHITE else Color.WHITE
[docs] def pop(self, is_finished: bool = True) -> Move: """ Undo the last move. Args: is_finished: If True, switches turn back. Set to False during internal move generation. Returns: The :class:`Move` that was undone. Raises: IndexError: If no moves have been made. Example: >>> board = Board() >>> board.push_uci("31-27") >>> board.pop() Move: 31->27 """ move = self._moves_stack.pop() src, tgt = move.square_list[0], move.square_list[-1] piece = self._get(tgt) if move.is_promotion: piece //= 2 self._set(tgt, 0) self._set(src, piece) for cap_sq, cap_piece in zip(move.captured_list, move.captured_entities): self._set(cap_sq, cap_piece) self.halfmove_clock = move.halfmove_clock if is_finished: self.turn = Color.BLACK if self.turn == Color.WHITE else Color.WHITE return move
[docs] def push_uci(self, str_move: str) -> None: """ Make a move using UCI notation. Args: str_move: Move in UCI format, e.g. ``"31-27"`` for quiet moves or ``"26x17"`` for captures. Raises: ValueError: If the move is not legal in the current position. Example: >>> board = Board() >>> board.push_uci("31-27") >>> board.push_uci("18-22") """ try: move = Move.from_uci(str_move, self.legal_moves) except ValueError as e: logger.error(f"{e}\n{self}") raise self.push(move)
@property def is_threefold_repetition(self) -> bool: """ Check for threefold repetition draw. Returns: True if the same position has occurred three times. """ if len(self._moves_stack) >= 9: s = self._moves_stack if s[-1].square_list == s[-5].square_list == s[-9].square_list: return True return False @property def game_over(self) -> bool: """ Check if the game has ended. Returns: True if drawn or if the current player has no legal moves. """ return self.is_draw or not self.legal_moves @property def result(self) -> Literal["1/2-1/2", "1-0", "0-1", "-"]: """ Get the game result. Returns: - ``"1-0"``: White wins - ``"0-1"``: Black wins - ``"1/2-1/2"``: Draw - ``"-"``: Game ongoing """ if self.is_draw: return "1/2-1/2" if self.game_over: return "0-1" if self.turn == Color.WHITE else "1-0" return "-"
[docs] @staticmethod def is_capture(move: Move) -> bool: """ Check if a move is a capture. Args: move: The move to check. Returns: True if the move captures at least one piece. """ return bool(move.captured_list)
@property def fen(self) -> str: """ Get the FEN string for the current position. Returns: FEN string, e.g. ``'[FEN "W:W31,32:B1,2"]'``. Kings are prefixed with 'K'. Example: >>> board = Board() >>> print(board.fen) """ turn_s = "W" if self.turn == Color.WHITE else "B" white_sq, black_sq = [], [] for sq in range(self.SQUARES_COUNT): bit = 1 << sq if self.white_men & bit: white_sq.append(str(sq + 1)) elif self.white_kings & bit: white_sq.append(f"K{sq + 1}") if self.black_men & bit: black_sq.append(str(sq + 1)) elif self.black_kings & bit: black_sq.append(f"K{sq + 1}") return f'[FEN "W:{turn_s}:W{",".join(white_sq)}:B{",".join(black_sq)}"]'
[docs] @classmethod def from_fen(cls, fen: str) -> BaseBoard: """ Create a board from a FEN string. Args: fen: FEN string, e.g. ``"W:W31,32:B1,2"`` or ``"W:WK10,K20:BK35,K45"``. Returns: New board instance with the specified position. Raises: ValueError: If the FEN string is invalid. Example: >>> board = Board.from_fen("W:WK10,K20:BK35,K45") """ logger.debug(f"Initializing from FEN: {fen}") fen = fen.upper() fen = re.sub(r"(G[0-9]+|P[0-9]+)(,|)", "", fen) prefix = re.search(r"[WB]:[WB]:[WB]", fen) if prefix: fen = fen.replace(prefix.group(0), prefix.group(0)[2:]) turn_m, white_m, black_m = re.search(r"[WB]:", fen), re.search(r"W[0-9K,]+", fen), re.search(r"B[0-9K,]+", fen) if not turn_m or not white_m or not black_m: raise ValueError(f"Invalid FEN: {fen}") position = np.zeros(cls.SQUARES_COUNT, dtype=np.int8) for sq_str in white_m.group(0)[1:].split(","): if sq_str.isdigit(): position[int(sq_str) - 1] = -1 elif sq_str.startswith("K"): position[int(sq_str[1:]) - 1] = -2 for sq_str in black_m.group(0)[1:].split(","): if sq_str.isdigit(): position[int(sq_str) - 1] = 1 elif sq_str.startswith("K"): position[int(sq_str[1:]) - 1] = 2 return cls(position, Color.WHITE if turn_m.group(0)[0] == "W" else Color.BLACK)
@property def pdn(self) -> str: """ Get the PDN string for the game so far. Returns: PDN string with headers and move list. Example: >>> board = Board() >>> board.push_uci("31-27") >>> print(board.pdn) """ header = f'[GameType "{self.GAME_TYPE}"]\n[Variant "{self.VARIANT_NAME}"]\n[Result "{self.result}"]\n' moves: list[list[str]] = [] for i, m in enumerate(self._moves_stack): if i % 2 == 0: moves.append([str(i // 2 + 1), str(m)]) else: moves[-1].append(str(m)) moves_str = " ".join(f"{m[0]}. {' '.join(m[1:])}" for m in moves) return header + moves_str + ("" if self.result == "-" else f" {self.result}")
[docs] @classmethod def from_pdn(cls, pdn: str) -> BaseBoard: """ Create a board by replaying moves from a PDN string. Supports both numeric (e.g., '33-28') and algebraic (e.g., 'c3-d4') notation. Args: pdn: PDN string with optional headers and move list. Returns: Board with all moves from the PDN applied. Raises: ValueError: If a move in the PDN is illegal. Example: >>> pdn = '[GameType "20"]\\n1. 32-28 19-23' >>> board = Board.from_pdn(pdn) """ board = cls() alg_to_idx = {name: idx for idx, name in enumerate(cls.SQUARE_NAMES)} if cls.SQUARE_NAMES else {} # Extract moves - try algebraic first, fall back to numeric alg_moves = re.findall(r'\b([a-h]\d[-x][a-h]\d)\b', pdn) if alg_moves and alg_to_idx: moves = [board._alg_to_uci(m, alg_to_idx) for m in alg_moves] else: results = {"2-0", "0-2", "1-1", "1-0", "0-1", "1/2-1/2"} moves = [m for m in re.findall(r'\b(\d+[-x]\d+(?:[-x]\d+)*)\b', pdn) if m not in results] # Parse moves, handling split multi-captures i, chain_start = 0, None while i < len(moves): move, is_cap = moves[i], 'x' in moves[i] start, end = int(move.split('x' if is_cap else '-')[0]), int(move.split('x' if is_cap else '-')[-1]) if not is_cap: board.push_uci(move) chain_start = None else: src = chain_start or start cap = next((m for m in board.legal_moves if m.captured_list and m.square_list[0] == src - 1 and (end - 1) in m.square_list), None) if not cap: raise ValueError(f"No legal capture for {move}") # Check if next move continues this capture chain if i + 1 < len(moves) and 'x' in moves[i + 1]: nxt = moves[i + 1] nxt_start = int(nxt.split('x')[0]) if nxt_start == end and (end - 1) in cap.square_list[1:-1]: chain_start = src i += 1 continue board.push(cap) chain_start = None i += 1 return board
@staticmethod def _alg_to_uci(move: str, mapping: dict[str, int]) -> str: """Convert algebraic notation (c3-d4) to UCI (22-18).""" sep = 'x' if 'x' in move else '-' parts = move.lower().split(sep) return f"{mapping[parts[0]] + 1}{sep}{mapping[parts[1]] + 1}" @property def position(self) -> np.ndarray: """ Get the board as a numpy array. Returns: 1D numpy array of length ``SQUARES_COUNT`` with piece values: 1=black man, 2=black king, -1=white man, -2=white king, 0=empty. Example: >>> board = Board() >>> pos = board.position >>> print(pos.shape) # (50,) for standard board """ arr = np.zeros(self.SQUARES_COUNT, dtype=np.int8) for sq in range(self.SQUARES_COUNT): arr[sq] = self._get(sq) return arr @property def _pos(self) -> np.ndarray: return self.position @property def friendly_form(self) -> np.ndarray: """ Get the board as a 2D-like array including empty (non-playable) squares. Returns: Numpy array representing the full board grid. """ pos, n = self.position, self.shape[0] // 2 new_pos = [0] for idx, sq in enumerate(pos): new_pos.extend([0] * (idx % n != 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) def __repr__(self) -> str: pos, n = self.friendly_form, self.shape[0] return "".join(f" {FIGURE_REPR[pos[i * n + j]]}" + ("\n" if j == n - 1 else "") for i in range(n) for j in range(n)) def __str__(self) -> str: n = self.shape[0] lines = [] for i, line in enumerate(repr(self).strip().split('\n')): sq = iter(range(i * n // 2 + 1, (i + 1) * n // 2 + 1)) nums = ' '.join(f"{next(sq):2d}" if (i + j) % 2 else "." for j in range(n)) lines.append(f"{line} {nums}") return '\n'.join(lines) def __iter__(self) -> Generator[int, None, None]: for sq in range(self.SQUARES_COUNT): yield self._get(sq) def __getitem__(self, key: int) -> int: return self._get(key) # ========================================================================= # AI / ML Support Methods # =========================================================================
[docs] def copy(self) -> "BaseBoard": """ Create a fast, cheap copy of the board. This is optimized for tree search - it copies only the essential state (bitboards, turn, halfmove clock) without deep copying the move stack. The new board has an empty move stack. Returns: A new board instance with the same position. Example: >>> board = Board() >>> board.push_uci("31-27") >>> clone = board.copy() >>> clone.push_uci("18-22") # Doesn't affect original >>> len(board._moves_stack) # Original unchanged 1 """ new = object.__new__(self.__class__) new.white_men = self.white_men new.white_kings = self.white_kings new.black_men = self.black_men new.black_kings = self.black_kings new.turn = self.turn new.halfmove_clock = self.halfmove_clock new.shape = self.shape new._moves_stack = [] return new
def __copy__(self) -> "BaseBoard": """Support for copy.copy().""" return self.copy() def __deepcopy__(self, memo: dict) -> "BaseBoard": """Support for copy.deepcopy() - includes move stack.""" new = self.copy() new._moves_stack = copy.deepcopy(self._moves_stack, memo) return new
[docs] def features(self) -> BoardFeatures: """ Extract features from the current position for AI/ML use. Returns a lightweight, immutable dataclass containing piece counts, material balance, mobility, and game phase. Computed on-demand with no caching to avoid memory overhead. Returns: :class:`BoardFeatures` with extracted position information. Example: >>> board = Board() >>> f = board.features() >>> print(f.white_men, f.black_men) # 20 20 >>> print(f.phase) # 'opening' """ wm = self._popcount(self.white_men) wk = self._popcount(self.white_kings) bm = self._popcount(self.black_men) bk = self._popcount(self.black_kings) total = wm + wk + bm + bk if total >= self.SQUARES_COUNT * 0.6: phase = "opening" elif total <= 8: phase = "endgame" else: phase = "midgame" return BoardFeatures( white_men=wm, white_kings=wk, black_men=bm, black_kings=bk, turn=1 if self.turn == Color.WHITE else -1, mobility=len(self.legal_moves), material_balance=(wm + 2 * wk) - (bm + 2 * bk), phase=phase, )
[docs] def to_tensor(self, perspective: Optional[Color] = None) -> np.ndarray: """ Convert board to tensor representation for neural networks. Returns a 4-channel representation: - Channel 0: Own men (1 where present, 0 elsewhere) - Channel 1: Own kings (1 where present, 0 elsewhere) - Channel 2: Opponent men (1 where present, 0 elsewhere) - Channel 3: Opponent kings (1 where present, 0 elsewhere) Args: perspective: The player's perspective. If None, uses current turn. From this perspective, "own" pieces are in channels 0-1. Returns: numpy array of shape ``(4, SQUARES_COUNT)`` with float32 dtype. For a 10x10 board, shape is ``(4, 50)``. Example: >>> board = Board() >>> tensor = board.to_tensor() >>> print(tensor.shape) # (4, 50) >>> # Channel 0 = white men, Channel 2 = black men (white's perspective) >>> print(tensor[0].sum()) # 20.0 (20 white men) Note: This method does NOT slow down normal board operations. It creates the tensor only when called. """ if perspective is None: perspective = self.turn tensor = np.zeros((4, self.SQUARES_COUNT), dtype=np.float32) if perspective == Color.WHITE: own_men, own_kings = self.white_men, self.white_kings opp_men, opp_kings = self.black_men, self.black_kings else: own_men, own_kings = self.black_men, self.black_kings opp_men, opp_kings = self.white_men, self.white_kings for sq in range(self.SQUARES_COUNT): bit = 1 << sq if own_men & bit: tensor[0, sq] = 1.0 elif own_kings & bit: tensor[1, sq] = 1.0 elif opp_men & bit: tensor[2, sq] = 1.0 elif opp_kings & bit: tensor[3, sq] = 1.0 return tensor
[docs] def legal_moves_mask(self) -> np.ndarray: """ Get a boolean mask indicating which move indices are legal. This is useful for masking neural network policy outputs. The mask has True at indices corresponding to legal moves and False elsewhere. The move index is computed as: ``from_square * SQUARES_COUNT + to_square`` Returns: numpy array of shape ``(SQUARES_COUNT * SQUARES_COUNT,)`` with dtype bool. For a 10x10 board, shape is ``(2500,)``. Example: >>> board = Board() >>> mask = board.legal_moves_mask() >>> print(mask.shape) # (2500,) for 10x10 board >>> policy = model(board.to_tensor()) # Your NN output >>> policy[~mask] = float('-inf') # Mask illegal moves >>> move_idx = policy.argmax() >>> move = board.index_to_move(move_idx) Note: For captures that visit multiple squares, only the start and final destination are used for indexing. """ n = self.SQUARES_COUNT mask = np.zeros(n * n, dtype=bool) for move in self.legal_moves: idx = move.square_list[0] * n + move.square_list[-1] mask[idx] = True return mask
[docs] def move_to_index(self, move: Move) -> int: """ Convert a move to a policy index. The index encodes the move as: ``from_square * SQUARES_COUNT + to_square`` Args: move: The move to convert. Returns: Integer index in range ``[0, SQUARES_COUNT^2)``. Example: >>> board = Board() >>> move = board.legal_moves[0] >>> idx = board.move_to_index(move) >>> recovered = board.index_to_move(idx) >>> move == recovered # True """ return move.square_list[0] * self.SQUARES_COUNT + move.square_list[-1]
[docs] def index_to_move(self, index: int) -> Move: """ Convert a policy index back to a move. Finds the legal move matching the encoded from/to squares. Args: index: Policy index from ``move_to_index`` or network output. Returns: The matching :class:`Move` object from legal moves. Raises: ValueError: If no legal move matches the index. Example: >>> board = Board() >>> move = board.index_to_move(1530) # sq 30 -> sq 30 % 50 = 30 """ n = self.SQUARES_COUNT from_sq = index // n to_sq = index % n for move in self.legal_moves: if move.square_list[0] == from_sq and move.square_list[-1] == to_sq: return move raise ValueError( f"No legal move from square {from_sq + 1} to {to_sq + 1}. " f"Legal moves: {list(map(str, self.legal_moves))}" )