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 FIGURE_REPR, Color, Figure
from draughts.move import Move

__all__ = ["BaseBoard", "BoardFeatures", "Color", "Figure", "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. Raises: ValueError: If the move does not start from a square occupied by a piece of the side to move (e.g. an empty square or an opponent piece). This guards against applying a stale or foreign :class:`Move` that would silently corrupt the position. Example: >>> board = Board() >>> move = board.legal_moves[0] >>> board.push(move) """ src, tgt = move.square_list[0], move.square_list[-1] piece = self._get(src) # Reject moves whose source holds no piece of the side to move. White # pieces are negative, black positive; an empty square is 0. Without # this, pushing such a move falls through to the black-king branch and # corrupts the board (see issue #27). if piece == 0 or (piece < 0) != (self.turn == Color.WHITE): raise ValueError( f"Illegal move {move}: square {src + 1} holds no " f"{'white' if self.turn == Color.WHITE else 'black'} piece to move." ) move.halfmove_clock = self.halfmove_clock 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. ``move.is_promotion`` may already be set by variants with # mid-capture promotion (e.g. Russian), where a man crowns part-way # through a capture and finishes on a square off the promotion rank. promoted = False if piece == -1 and ((self.PROMO_WHITE & tgt_bit) or move.is_promotion): self.white_men &= ~tgt_bit self.white_kings |= tgt_bit move.is_promotion = True promoted = True elif piece == 1 and ((self.PROMO_BLACK & tgt_bit) or move.is_promotion): self.black_men &= ~tgt_bit self.black_kings |= tgt_bit move.is_promotion = True promoted = True # Halfmove clock: only quiet king moves advance it; promotions, # captures and man moves are irreversible progress and reset it. if not promoted and 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)
def _legal_moves_from_core( self, core, *, max_capture: bool, captures_optional: bool = False ) -> list[Move]: """Build the variant's legal moves from a unified ``_core.MoveGen``. Shared by every diagonal variant (standard/american/russian/brazilian). Each side's rule differences reduce to two flags: * ``max_capture`` -- keep only the longest capture chains and drop duplicate-outcome routes (Standard/Brazilian). ``False`` returns every capture chain (Russian free choice). * ``captures_optional`` -- captures do not force out quiet moves, so both are offered, quiets first (American). Otherwise captures, when present, replace the quiet moves entirely. The ``is_promotion`` flag is threaded uniformly from the core into every capture ``Move``; only Russian's core sets it, and it is always ``False`` for the other variants (harmless). The core hands back capture paths as tuples (cheaper to snapshot at each chain terminal than lists) and this materializes the ``Move`` square lists only for the moves it returns -- the maximum-capture variants discard most chains, so copying them into lists up front would be wasted work. Quiet moves already arrive as the fresh two-element lists ``Move`` needs. """ to_ghost = core.to_ghost wm = to_ghost(self.white_men) wk = to_ghost(self.white_kings) bm = to_ghost(self.black_men) bk = to_ghost(self.black_kings) white = self.turn == Color.WHITE caps, quiets = core.gen_moves(wm, wk, bm, bk, white, captures_optional) get = self._get if captures_optional: # American: quiets first (historical ordering), then every capture. moves = [Move(pair) for pair in quiets] moves.extend( Move(list(path), list(cap), [get(c) for c in cap], promo) for path, cap, promo in caps ) return moves if caps: if max_capture: # Manual max (not ``max(genexpr)``): the generator frame's # per-item overhead is measurable on capture-heavy 10x10 nodes. best = 0 for _path, cap, _promo in caps: if len(cap) > best: best = len(cap) moves = [ Move(list(path), list(cap), [get(c) for c in cap], promo) for path, cap, promo in caps if len(cap) == best ] return self._dedupe_captures(moves) return [ Move(list(path), list(cap), [get(c) for c in cap], promo) for path, cap, promo in caps ] return [Move(pair) for pair in quiets] @staticmethod def _dedupe_captures(captures: list[Move]) -> list[Move]: """ Drop capture sequences that are indistinguishable outcomes. A "windmill" king capture can reach the same landing square while capturing the same set of pieces via more than one visiting order (e.g. ``2x13x22x11x2`` and ``2x11x22x13x2`` on ``W:WK2:B7,8,17,18``). Those leave a byte-identical position, so only the first is kept (issue #34). Moves that differ in start square, landing square, the set of captured squares, or promotion outcome are all preserved, so genuinely distinct routes (issue #29) are untouched. Args: captures: Candidate capture moves, already filtered to the ones that are legal for the variant. Returns: The input list with duplicate-outcome moves removed, order kept. """ seen: set[tuple] = set() unique: list[Move] = [] for move in captures: key = ( move.square_list[0], move.square_list[-1], tuple(sorted(move.captured_list)), move.is_promotion, ) if key in seen: continue seen.add(key) unique.append(move) return unique @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 "{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") >>> board = Board.from_fen("W:W31-50:B1-20") # ranges (issue #33) """ logger.debug(f"Initializing from FEN: {fen}") fen = fen.upper() fen = re.sub(r"(G[0-9]+|P[0-9]+)(,|)", "", fen) # Unwrap the optional ``[FEN "..."]`` container so the colon-separated # fields can be counted reliably below. wrap = re.search(r'\[FEN\s*"([^"]*)"\]', fen) if wrap: fen = wrap.group(1) # Older versions emitted a redundant leading side-to-move token, e.g. # ``W:B:W...:B...`` instead of the canonical ``B:W...:B...``. A canonical # FEN has exactly three colon-separated fields (turn, white list, black # list); the legacy form has four, with a bare extra turn letter in # front. Drop that leading token so both forms parse identically. # Counting fields (rather than a ``[WB]:[WB]:[WB]`` regex) avoids # misreading a one-sided position such as ``B:W:B1`` (empty white side) # as if it carried a legacy prefix. fields = fen.split(":") if len(fields) == 4 and fields[0] in ("W", "B"): del fields[0] fen = ":".join(fields) # A canonical FEN starts with three fields: ``<turn>:W<list>:B<list>``. # Anchoring the start (``^``) and requiring each list to be a contiguous # run of ``[-0-9K,]`` rejects a stray piece character such as the ``W`` in # ``W:WK4,WK5,55:B4`` (the class cannot cross it to reach ``:B``) instead # of silently truncating the list. The end is left unanchored so optional # trailing counter fields (e.g. ``:H0:F2``) are tolerated. ``[-0-9K,]*`` # permits an empty list (a side with no pieces left, which ``fen`` # legitimately emits) and the ``-`` needed for square ranges (issue #33). if not (r := re.match(r"^([BW]):W([-0-9K,]*):B([-0-9K,]*)", fen)): raise ValueError(f"Invalid FEN: {fen}") position = np.zeros(cls.SQUARES_COUNT, dtype=np.int8) for group, king_val, man_val in ((r.group(2), -2, -1), (r.group(3), 2, 1)): if not group: continue for sq_str in group.split(","): for piece in cls.parse_square(sq_str, cls.SQUARES_COUNT): idx = piece["square"] - 1 if position[idx] != 0: raise ValueError(f"Duplicate square in FEN: {piece['square']}") position[idx] = king_val if piece["king"] else man_val return cls(position, Color.WHITE if r.group(1) == "W" else Color.BLACK)
@staticmethod def parse_square(sq: str, max_square: int) -> list[dict[str, int | bool]]: """ Parse one square token from a FEN piece list. A token is a single square (``"4"``), a king square (``"K4"``), or a range covering several consecutive squares (``"31-50"`` / ``"K31-50"``). Ranges expand to one entry per square (issue #33). Args: sq: Square token, e.g. ``"4"``, ``"K20"`` or ``"31-50"``. max_square: Highest legal square number for the variant. Returns: List of ``{"king": bool, "square": int}`` dicts, one per square. Raises: ValueError: If the token is malformed or references a square outside the legal range ``1..max_square``. Example: >>> BaseBoard.parse_square("K4-6", 50) [{'king': True, 'square': 4}, {'king': True, 'square': 5}, {'king': True, 'square': 6}] """ if not (m := re.match(r"^(K?)([0-9]{1,2})(?:-([0-9]{1,2}))?$", sq)): raise ValueError(f"Invalid square in FEN: {sq}") is_king, start, end = m.group(1) == "K", int(m.group(2)), m.group(3) if end is None: if not 1 <= start <= max_square: raise ValueError(f"Invalid square in FEN: {sq}") return [{"king": is_king, "square": start}] stop = int(end) if not 1 <= start < stop <= max_square: raise ValueError(f"Invalid square range in FEN: {sq}") return [{"king": is_king, "square": i} for i in range(start, stop + 1)] @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))}" )