"""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))}"
)