Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 33 additions & 22 deletions chess/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -907,16 +907,19 @@ def color_at(self, square: Square) -> Optional[Color]:
else:
return None

def _effective_promoted(self) -> Bitboard:
return BB_EMPTY

def king(self, color: Color) -> Optional[Square]:
"""
Finds the king square of the given side. Returns ``None`` if there
is no king of that color.
Finds the unique king square of the given side. Returns ``None`` if
there is no king or multiple kings of that color.

In variants with king promotions, only non-promoted kings are
considered.
"""
king_mask = self.occupied_co[color] & self.kings & ~self.promoted
return msb(king_mask) if king_mask and popcount(king_mask) == 1 else None
king_mask = self.occupied_co[color] & self.kings & ~self._effective_promoted()
return msb(king_mask) if king_mask and not king_mask & (king_mask - 1) else None

def attacks_mask(self, square: Square) -> Bitboard:
bb_square = BB_SQUARES[square]
Expand Down Expand Up @@ -1135,7 +1138,7 @@ def set_piece_at(self, square: Square, piece: Optional[Piece], promoted: bool =
else:
self._set_piece_at(square, piece.piece_type, piece.color, promoted)

def board_fen(self, *, promoted: Optional[bool] = False) -> str:
def board_fen(self, *, promoted: Optional[bool] = None) -> str:
"""
Gets the board FEN (e.g.,
``rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR``).
Expand All @@ -1153,7 +1156,14 @@ def board_fen(self, *, promoted: Optional[bool] = False) -> str:
builder.append(str(empty))
empty = 0
builder.append(piece.symbol())
if promoted and BB_SQUARES[square] & self.promoted:

if promoted is None:
promoted_mask = self._effective_promoted()
elif promoted:
promoted_mask = self.promoted
else:
promoted_mask = BB_EMPTY
if BB_SQUARES[square] & promoted_mask:
Comment on lines +1160 to +1166
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

board_fen() recomputes promoted_mask (and may call _effective_promoted()) inside the per-square loop. Since promoted does not change while building the string, compute promoted_mask once before iterating squares to avoid repeated work in this hot path.

Copilot uses AI. Check for mistakes.
builder.append("~")

if BB_SQUARES[square] & BB_FILE_H:
Expand Down Expand Up @@ -1335,7 +1345,7 @@ def chess960_pos(self) -> Optional[int]:
return None
if self.pawns != BB_RANK_2 | BB_RANK_7:
return None
if self.promoted:
if self._effective_promoted():
return None

# Piece counts.
Expand Down Expand Up @@ -2452,12 +2462,12 @@ def push(self, move: Move) -> None:

# Update castling rights.
self.castling_rights &= ~to_bb & ~from_bb
if piece_type == KING and not promoted:
if piece_type == KING and not self._effective_promoted() & from_bb:
if self.turn == WHITE:
self.castling_rights &= ~BB_RANK_1
else:
self.castling_rights &= ~BB_RANK_8
elif captured_piece_type == KING and not self.promoted & to_bb:
elif captured_piece_type == KING and not self._effective_promoted() & to_bb:
if self.turn == WHITE and square_rank(move.to_square) == RANK_8:
self.castling_rights &= ~BB_RANK_8
elif self.turn == BLACK and square_rank(move.to_square) == RANK_1:
Expand Down Expand Up @@ -3404,8 +3414,8 @@ def _reduces_castling_rights(self, move: Move) -> bool:
cr = self.clean_castling_rights()
touched = BB_SQUARES[move.from_square] ^ BB_SQUARES[move.to_square]
return bool(touched & cr or
cr & BB_RANK_1 and touched & self.kings & self.occupied_co[WHITE] & ~self.promoted or
cr & BB_RANK_8 and touched & self.kings & self.occupied_co[BLACK] & ~self.promoted)
cr & BB_RANK_1 and touched & self.kings & self.occupied_co[WHITE] & ~self._effective_promoted() or
cr & BB_RANK_8 and touched & self.kings & self.occupied_co[BLACK] & ~self._effective_promoted())

def is_irreversible(self, move: Move) -> bool:
"""
Expand Down Expand Up @@ -3459,16 +3469,16 @@ def clean_castling_rights(self) -> Bitboard:
black_castling &= (BB_A8 | BB_H8)

# The kings must be on e1 or e8.
if not self.occupied_co[WHITE] & self.kings & ~self.promoted & BB_E1:
if not self.occupied_co[WHITE] & self.kings & ~self._effective_promoted() & BB_E1:
white_castling = 0
if not self.occupied_co[BLACK] & self.kings & ~self.promoted & BB_E8:
if not self.occupied_co[BLACK] & self.kings & ~self._effective_promoted() & BB_E8:
black_castling = 0

return white_castling | black_castling
else:
# The kings must be on the back rank.
white_king_mask = self.occupied_co[WHITE] & self.kings & BB_RANK_1 & ~self.promoted
black_king_mask = self.occupied_co[BLACK] & self.kings & BB_RANK_8 & ~self.promoted
white_king_mask = self.occupied_co[WHITE] & self.kings & BB_RANK_1 & ~self._effective_promoted()
black_king_mask = self.occupied_co[BLACK] & self.kings & BB_RANK_8 & ~self._effective_promoted()
if not white_king_mask:
white_castling = 0
if not black_king_mask:
Expand Down Expand Up @@ -3506,7 +3516,7 @@ def has_kingside_castling_rights(self, color: Color) -> bool:
castling rights.
"""
backrank = BB_RANK_1 if color == WHITE else BB_RANK_8
king_mask = self.kings & self.occupied_co[color] & backrank & ~self.promoted
king_mask = self.kings & self.occupied_co[color] & backrank & ~self._effective_promoted()
if not king_mask:
return False

Expand All @@ -3527,7 +3537,7 @@ def has_queenside_castling_rights(self, color: Color) -> bool:
castling rights.
"""
backrank = BB_RANK_1 if color == WHITE else BB_RANK_8
king_mask = self.kings & self.occupied_co[color] & backrank & ~self.promoted
king_mask = self.kings & self.occupied_co[color] & backrank & ~self._effective_promoted()
if not king_mask:
return False

Expand Down Expand Up @@ -3600,11 +3610,11 @@ def status(self) -> Status:
errors |= STATUS_EMPTY

# There must be exactly one king of each color.
if not self.occupied_co[WHITE] & self.kings:
if not self.occupied_co[WHITE] & self.kings & ~self._effective_promoted():
errors |= STATUS_NO_WHITE_KING
if not self.occupied_co[BLACK] & self.kings:
if not self.occupied_co[BLACK] & self.kings & ~self._effective_promoted():
errors |= STATUS_NO_BLACK_KING
if popcount(self.occupied & self.kings) > 2:
if popcount(self.occupied & self.kings & ~self._effective_promoted()) > 2:
errors |= STATUS_TOO_MANY_KINGS

# There can not be more than 16 pieces of any color.
Expand Down Expand Up @@ -3638,7 +3648,7 @@ def status(self) -> Status:

# More than the maximum number of possible checkers in the variant.
checkers = self.checkers_mask()
our_kings = self.kings & self.occupied_co[self.turn] & ~self.promoted
our_kings = self.kings & self.occupied_co[self.turn] & ~self._effective_promoted()
if checkers:
if popcount(checkers) > 2:
errors |= STATUS_TOO_MANY_CHECKERS
Expand Down Expand Up @@ -3822,7 +3832,7 @@ def generate_castling_moves(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboar
return

backrank = BB_RANK_1 if self.turn == WHITE else BB_RANK_8
king = self.occupied_co[self.turn] & self.kings & ~self.promoted & backrank & from_mask
king = self.occupied_co[self.turn] & self.kings & ~self._effective_promoted() & backrank & from_mask
king &= -king
if not king:
return
Expand Down Expand Up @@ -3879,6 +3889,7 @@ def _to_chess960(self, move: Move) -> Move:
def _transposition_key(self) -> Hashable:
return (self.pawns, self.knights, self.bishops, self.rooks,
self.queens, self.kings,
self._effective_promoted(),
self.occupied_co[WHITE], self.occupied_co[BLACK],
self.turn, self.clean_castling_rights(),
self.ep_square if self.has_legal_en_passant() else None)
Expand Down
27 changes: 8 additions & 19 deletions chess/variant.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,8 @@ def is_legal(self, move: chess.Move) -> bool:
else:
return not any(self.generate_pseudo_legal_captures())

def _transposition_key(self) -> Hashable:
if self.has_chess960_castling_rights():
return (super()._transposition_key(), self.kings & self.promoted)
else:
return super()._transposition_key()

def board_fen(self, *, promoted: Optional[bool] = None) -> str:
if promoted is None:
promoted = self.has_chess960_castling_rights()
return super().board_fen(promoted=promoted)
def _effective_promoted(self) -> chess.Bitboard:
return self.kings & self.promoted if self.castling_rights else chess.BB_EMPTY
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In SuicideBoard._effective_promoted(), gating on raw self.castling_rights can mark promoted kings as “effective” even when there are no valid castling rights (e.g. after parsing an invalid FEN where clean_castling_rights() would be empty). This can make fen()/epd() output and _transposition_key() depend on a castling-rights state that would otherwise be ignored. Consider basing the condition on castling rights that survive basic validation (for example, requiring a corresponding rook on the castling-rights square) so effective promotions track actually usable castling rights.

Suggested change
return self.kings & self.promoted if self.castling_rights else chess.BB_EMPTY
return self.kings & self.promoted if self.clean_castling_rights() else chess.BB_EMPTY

Copilot uses AI. Check for mistakes.

def status(self) -> chess.Status:
status = super().status()
Expand Down Expand Up @@ -261,9 +253,9 @@ def _push_capture(self, move: chess.Move, capture_square: chess.Square, piece_ty

# Destroy castling rights.
self.castling_rights &= ~explosion_radius
if explosion_radius & self.kings & self.occupied_co[chess.WHITE] & ~self.promoted:
if explosion_radius & self.kings & self.occupied_co[chess.WHITE] & ~self._effective_promoted():
self.castling_rights &= ~chess.BB_RANK_1
if explosion_radius & self.kings & self.occupied_co[chess.BLACK] & ~self.promoted:
if explosion_radius & self.kings & self.occupied_co[chess.BLACK] & ~self._effective_promoted():
self.castling_rights &= ~chess.BB_RANK_8

# Explode the capturing piece.
Expand Down Expand Up @@ -930,9 +922,11 @@ def _is_halfmoves(self, n: int) -> bool:
def is_irreversible(self, move: chess.Move) -> bool:
return self._reduces_castling_rights(move)

def _effective_promoted(self) -> chess.Bitboard:
return self.promoted & ~self.kings & ~self.pawns

def _transposition_key(self) -> Hashable:
return (super()._transposition_key(),
self.promoted,
str(self.pockets[chess.WHITE]), str(self.pockets[chess.BLACK]))

def legal_drop_squares_mask(self) -> chess.Bitboard:
Expand Down Expand Up @@ -1009,7 +1003,7 @@ def has_insufficient_material(self, color: chess.Color) -> bool:
# a different color complex.
return (
chess.popcount(self.occupied) + sum(len(pocket) for pocket in self.pockets) <= 3 and
not self.promoted and
not self._effective_promoted() and
not self.pawns and
not self.rooks and
not self.queens and
Expand Down Expand Up @@ -1041,11 +1035,6 @@ def set_fen(self, fen: str) -> None:
self.pockets[chess.WHITE] = white_pocket
self.pockets[chess.BLACK] = black_pocket

def board_fen(self, *, promoted: Optional[bool] = None) -> str:
if promoted is None:
promoted = True
return super().board_fen(promoted=promoted)

def epd(self, shredder: bool = False, en_passant: chess.EnPassantSpec = "legal", promoted: Optional[bool] = None, **operations: Union[None, str, int, float, chess.Move, Iterable[chess.Move]]) -> str:
epd = super().epd(shredder=shredder, en_passant=en_passant, promoted=promoted)
board_part, info_part = epd.split(" ", 1)
Expand Down