From f9062cf15c4352df86ae0fc54ce8c6f3a5bd4c76 Mon Sep 17 00:00:00 2001 From: Shubham Jain Date: Thu, 23 Jul 2026 12:16:29 +0530 Subject: [PATCH 1/2] added a new chess variant duck chess --- chess/variant.py | 238 +++++++++++++++++++++++++++++++++++++++++++++++ test.py | 67 +++++++++++++ 2 files changed, 305 insertions(+) diff --git a/chess/variant.py b/chess/variant.py index ba4c0f1c..4649afdf 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -1076,7 +1076,244 @@ def status(self) -> chess.Status: return status +class DuckChessBoard(chess.Board): + aliases = ["Duck", "Duck Chess", "Duckchess"] + uci_variant = "duck" + starting_fen = chess.STARTING_FEN + + def __init__(self, fen=starting_fen, chess960=False): + self.duck_square = None + self.duck_phase = False + self._duck_history = [] + super().__init__(fen, chess960=chess960) + + def reset_board(self): + super().reset_board() + self.duck_square = None + self.duck_phase = False + + def clear_board(self): + super().clear_board() + self.duck_square = None + self.duck_phase = False + + def add_duck_as_blocker(self): + self.saved_occupied = self.occupied + if self.duck_square is not None: + self.occupied |= chess.BB_SQUARES[self.duck_square] + + def remove_duck_as_blocker(self): + self.occupied = self.saved_occupied + + def generate_pseudo_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): + if self.duck_phase: + return + if self.duck_square is not None: + to_mask &= ~chess.BB_SQUARES[self.duck_square] + self.add_duck_as_blocker() + try: + moves = list(super().generate_pseudo_legal_moves(from_mask, to_mask)) + finally: + self.remove_duck_as_blocker() + for m in moves: + yield m + + def push(self, move): + self._duck_history.append((self.duck_square, self.duck_phase)) + if not self.duck_phase: + mover = self.turn + super().push(move) + self.turn = mover + if mover == chess.BLACK: + self.fullmove_number -= 1 + self.duck_phase = True + else: + self.duck_square = move.to_square + self.move_stack.append(move) + if self.turn == chess.BLACK: + self.fullmove_number += 1 + self.turn = not self.turn + self.duck_phase = False + + def pop(self): + prev_duck_square, prev_duck_phase = self._duck_history.pop() + if not prev_duck_phase: + move = super().pop() + else: + move = self.move_stack.pop() + self.turn = not self.turn + if self.turn == chess.BLACK: + self.fullmove_number -= 1 + self.duck_square = prev_duck_square + self.duck_phase = prev_duck_phase + return move + + def duck_field(self): + prefix = "@" if self.duck_phase else "" + if self.duck_square is None: + square_part = "-" + else: + square_part = chess.SQUARE_NAMES[self.duck_square] + return prefix + square_part + + def looks_like_duck_field(self, token): + if token.startswith("@"): + return True + if token == "-": + return True + if len(token) == 2 and token in chess.SQUARE_NAMES: + return True + return False + + def set_duck_field(self, token): + if token.startswith("@"): + self.duck_phase = True + body = token[1:] + else: + self.duck_phase = False + body = token + if body == "-": + self.duck_square = None + else: + self.duck_square = chess.SQUARE_NAMES.index(body) + def fen(self, **kwargs): + base = super().fen(**kwargs) + parts = base.split() + parts.insert(4, self.duck_field()) + return " ".join(parts) + + def set_fen(self, fen): + parts = fen.split() + if len(parts) > 4 and self.looks_like_duck_field(parts[4]): + duck_token = parts.pop(4) + else: + duck_token = "-" + super().set_fen(" ".join(parts)) + self.set_duck_field(duck_token) + + def generate_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): + if self.is_variant_end(): + return + if self.duck_phase: + yield from self.generate_duck_placements(from_mask, to_mask) + else: + yield from self.generate_pseudo_legal_moves(from_mask, to_mask) + + def generate_duck_placements(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): + empty_squares = chess.BB_ALL & ~self.occupied + if self.duck_square is not None: + empty_squares &= ~chess.BB_SQUARES[self.duck_square] + + empty_squares &= from_mask & to_mask + for square in chess.scan_reversed(empty_squares): + yield chess.Move(square, square) + + def is_pseudo_legal(self, move): + if self.duck_phase: + return False + if move.from_square == move.to_square: + return False + if self.duck_square is not None and move.to_square == self.duck_square: + return False + self.add_duck_as_blocker() + try: + return super().is_pseudo_legal(move) + finally: + self.remove_duck_as_blocker() + + def is_legal(self, move): + if self.duck_phase: + if move.from_square != move.to_square: + return False + if chess.BB_SQUARES[move.to_square] & self.occupied: + return False + if self.duck_square is not None and move.to_square == self.duck_square: + return False + return True + return self.is_pseudo_legal(move) + + def is_check(self): + return False + + def gives_check(self, move): + return False + + def is_into_check(self, move): + return False + + def was_into_check(self): + return False + + def is_checkmate(self): + return False + + def checkers_mask(self): + return chess.BB_EMPTY + + def is_fifty_moves(self): + return False + + def is_seventyfive_moves(self): + return False + + def is_fivefold_repetition(self): + return False + + def is_repetition(self, count=3): + return False + + def is_variant_end(self): + white_has_king = bool(self.kings & self.occupied_co[chess.WHITE]) + black_has_king = bool(self.kings & self.occupied_co[chess.BLACK]) + return not white_has_king or not black_has_king + + def is_variant_win(self): + my_king_alive = bool(self.kings & self.occupied_co[self.turn]) + their_king_alive = bool(self.kings & self.occupied_co[not self.turn]) + return my_king_alive and not their_king_alive + + def is_variant_loss(self): + my_king_alive = bool(self.kings & self.occupied_co[self.turn]) + their_king_alive = bool(self.kings & self.occupied_co[not self.turn]) + return their_king_alive and not my_king_alive + + def uci(self, move, *, chess960=None): + if move.from_square == move.to_square: + return "@" + chess.SQUARE_NAMES[move.to_square] + return super().uci(move, chess960=chess960) + + def parse_uci(self, uci): + if uci.startswith("@"): + square_name = uci[1:] + square = chess.SQUARE_NAMES.index(square_name) + move = chess.Move(square, square) + if not self.is_legal(move): + raise chess.IllegalMoveError(f"illegal duck placement: {uci!r}") + return move + return super().parse_uci(uci) + + def _algebraic_without_suffix(self, move, *, long=False): + if move.from_square == move.to_square: + return "@" + chess.SQUARE_NAMES[move.to_square] + return super()._algebraic_without_suffix(move, long=long) + + def parse_san(self, san): + if san.startswith("@"): + square_name = san[1:].rstrip("+#") + square = chess.SQUARE_NAMES.index(square_name) + move = chess.Move(square, square) + if not self.is_legal(move): + raise chess.IllegalMoveError(f"illegal duck san: {san!r}") + return move + return super().parse_san(san) + + def has_insufficient_material(self, color): + return False + + def _attacked_for_king(self, path, occupied): + return False + VARIANTS: List[Type[chess.Board]] = [ chess.Board, SuicideBoard, GiveawayBoard, AntichessBoard, @@ -1086,6 +1323,7 @@ def status(self) -> chess.Status: HordeBoard, ThreeCheckBoard, CrazyhouseBoard, + DuckChessBoard ] diff --git a/test.py b/test.py index ac906638..2467b8f3 100755 --- a/test.py +++ b/test.py @@ -4985,3 +4985,70 @@ def test_antichess_pgn(self): logging.getLogger().addHandler(raise_log_handler) unittest.main() + +class DuckChessTestCase(unittest.TestCase): + def test_duck_blocks_sliding_and_landing(self): + board = chess.variant.DuckChessBoard() + board.clear_board() + board.set_piece_at(chess.A1, chess.Piece(chess.ROOK, chess.WHITE)) + board.turn = chess.WHITE + board.duck_square = chess.A4 + moves = [m.to_square for m in board.generate_pseudo_legal_moves() if m.from_square == chess.A1] + self.assertNotIn(chess.A4, moves) + self.assertNotIn(chess.A5, moves) + self.assertIn(chess.A3, moves) + + def test_duck_blocks_knight_landing(self): + board = chess.variant.DuckChessBoard() + board.clear_board() + board.set_piece_at(chess.B1, chess.Piece(chess.KNIGHT, chess.WHITE)) + board.turn = chess.WHITE + board.duck_square = chess.C3 + moves = [m.to_square for m in board.generate_pseudo_legal_moves() if m.from_square == chess.B1] + self.assertNotIn(chess.C3, moves) + self.assertIn(chess.A3, moves) + + def test_turn_stays_until_duck_placed(self): + board = chess.variant.DuckChessBoard() + self.assertEqual(board.turn, chess.WHITE) + self.assertFalse(board.duck_phase) + board.push(chess.Move.from_uci("e2e4")) + self.assertEqual(board.turn, chess.WHITE) + self.assertTrue(board.duck_phase) + board.push(chess.Move(chess.E4, chess.E4)) + self.assertEqual(board.turn, chess.BLACK) + self.assertFalse(board.duck_phase) + + def test_no_check_or_checkmate(self): + board = chess.variant.DuckChessBoard.empty() + board.set_piece_at(chess.E1, chess.Piece(chess.KING, chess.WHITE)) + board.set_piece_at(chess.E8, chess.Piece(chess.KING, chess.BLACK)) + board.set_piece_at(chess.E7, chess.Piece(chess.ROOK, chess.WHITE)) + board.turn = chess.BLACK + self.assertFalse(board.is_check()) + self.assertFalse(board.is_checkmate()) + self.assertGreater(len(list(board.generate_legal_moves())), 0) + + def test_king_capture_ends_game(self): + board = chess.variant.DuckChessBoard.empty() + board.set_piece_at(chess.E1, chess.Piece(chess.KING, chess.WHITE)) + board.set_piece_at(chess.E8, chess.Piece(chess.KING, chess.BLACK)) + board.set_piece_at(chess.E7, chess.Piece(chess.ROOK, chess.WHITE)) + board.turn = chess.WHITE + self.assertFalse(board.is_variant_end()) + board.push(chess.Move.from_uci("e7e8")) + self.assertTrue(board.is_variant_end()) + self.assertEqual(list(board.generate_legal_moves()), []) + + def test_illegal_duck_placement(self): + board = chess.variant.DuckChessBoard() + board.push(chess.Move.from_uci("e2e4")) + with self.assertRaises(chess.IllegalMoveError): + board.parse_uci("@e4") + + def test_insufficient_material_never_true(self): + board = chess.variant.DuckChessBoard.empty() + board.set_piece_at(chess.E1, chess.Piece(chess.KING, chess.WHITE)) + board.set_piece_at(chess.E8, chess.Piece(chess.KING, chess.BLACK)) + self.assertFalse(board.has_insufficient_material(chess.WHITE)) + self.assertFalse(board.has_insufficient_material(chess.BLACK)) From aa009c63db655553985f845126aa513c78cc4a31 Mon Sep 17 00:00:00 2001 From: Shubham Jain Date: Thu, 23 Jul 2026 16:11:51 +0530 Subject: [PATCH 2/2] added duck chess documentation --- CHANGELOG.rst | 7 +++ README.rst | 2 +- chess/variant.py | 56 ++++++++++++------ docs/variant.rst | 31 ++++++++++ test.py | 144 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 03a9555d..7826b7ae 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,13 @@ Changelog for python-chess ========================== +Upcoming in the next release +----------------------------- + +New features: + +* **Variant support: Duck Chess.** + New in v1.11.2 (25th Feb 2025) ------------------------------ diff --git a/README.rst b/README.rst index e4b2166f..61e36793 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ Features :alt: r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR * Chess variants: Standard, Chess960, Suicide, Giveaway, Atomic, - King of the Hill, Racing Kings, Horde, Three-check, Crazyhouse. + King of the Hill, Racing Kings, Horde, Three-check, Crazyhouse, Duck Chess. `Variant docs `_. * Make and unmake moves. diff --git a/chess/variant.py b/chess/variant.py index 4649afdf..8c319f7f 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -1065,26 +1065,23 @@ def mirror(self) -> Self: def status(self) -> chess.Status: status = super().status() - if chess.popcount(self.pawns) + self.pockets[chess.WHITE].count(chess.PAWN) + self.pockets[chess.BLACK].count(chess.PAWN) <= 16: status &= ~chess.STATUS_TOO_MANY_BLACK_PAWNS status &= ~chess.STATUS_TOO_MANY_WHITE_PAWNS - if chess.popcount(self.occupied) + len(self.pockets[chess.WHITE]) + len(self.pockets[chess.BLACK]) <= 32: status &= ~chess.STATUS_TOO_MANY_BLACK_PIECES status &= ~chess.STATUS_TOO_MANY_WHITE_PIECES - return status class DuckChessBoard(chess.Board): aliases = ["Duck", "Duck Chess", "Duckchess"] - uci_variant = "duck" - starting_fen = chess.STARTING_FEN + uci_variant = "duck" + starting_fen = chess.STARTING_FEN def __init__(self, fen=starting_fen, chess960=False): - self.duck_square = None - self.duck_phase = False - self._duck_history = [] + self.duck_square = None # current duck square, or None if not yet placed + self.duck_phase = False # False: about to make a chess move; True: about to place the duck + self._duck_history = [] # (duck_square, duck_phase) snapshots so pop() can undo duck placements too super().__init__(fen, chess960=chess960) def reset_board(self): @@ -1098,6 +1095,9 @@ def clear_board(self): self.duck_phase = False def add_duck_as_blocker(self): + # Temporarily fold the duck into `occupied` so the base engine's bitboard + # move generation treats its square as blocked, without giving it a color + # or piece type. self.saved_occupied = self.occupied if self.duck_square is not None: self.occupied |= chess.BB_SQUARES[self.duck_square] @@ -1107,7 +1107,7 @@ def remove_duck_as_blocker(self): def generate_pseudo_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): if self.duck_phase: - return + return # no chess moves while waiting for a duck placement if self.duck_square is not None: to_mask &= ~chess.BB_SQUARES[self.duck_square] self.add_duck_as_blocker() @@ -1121,6 +1121,8 @@ def generate_pseudo_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_A def push(self, move): self._duck_history.append((self.duck_square, self.duck_phase)) if not self.duck_phase: + # Chess-move ply: delegate to the base engine, but undo the turn/ + # fullmove-number flip it performs mover = self.turn super().push(move) self.turn = mover @@ -1128,8 +1130,10 @@ def push(self, move): self.fullmove_number -= 1 self.duck_phase = True else: + # Duck-placement ply: not a real chess move, so bypass super().push() + # and just record it on the move stack, then hand the turn to the opponent for real. self.duck_square = move.to_square - self.move_stack.append(move) + self.move_stack.append(move) if self.turn == chess.BLACK: self.fullmove_number += 1 self.turn = not self.turn @@ -1140,7 +1144,8 @@ def pop(self): if not prev_duck_phase: move = super().pop() else: - move = self.move_stack.pop() + # Undo a duck placement + move = self.move_stack.pop() self.turn = not self.turn if self.turn == chess.BLACK: self.fullmove_number -= 1 @@ -1149,6 +1154,8 @@ def pop(self): return move def duck_field(self): + # Extra FEN field encoding the duck: square name, "-" if unplaced, and + # an "@" prefix if we're mid-turn waiting on a duck placement. prefix = "@" if self.duck_phase else "" if self.duck_square is None: square_part = "-" @@ -1157,6 +1164,7 @@ def duck_field(self): return prefix + square_part def looks_like_duck_field(self, token): + # Distinguishes a duck field from the standard FEN field it's inserted before . if token.startswith("@"): return True if token == "-": @@ -1178,6 +1186,8 @@ def set_duck_field(self, token): self.duck_square = chess.SQUARE_NAMES.index(body) def fen(self, **kwargs): + # Insert the duck field after the en passant field (index 4), keeping + # the rest of standard FEN layout intact. base = super().fen(**kwargs) parts = base.split() parts.insert(4, self.duck_field()) @@ -1201,6 +1211,8 @@ def generate_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): yield from self.generate_pseudo_legal_moves(from_mask, to_mask) def generate_duck_placements(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): + # A duck placement is represented as a null-ish move onto + # any empty square other than the duck's current one (it must move). empty_squares = chess.BB_ALL & ~self.occupied if self.duck_square is not None: empty_squares &= ~chess.BB_SQUARES[self.duck_square] @@ -1211,11 +1223,11 @@ def generate_duck_placements(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL) def is_pseudo_legal(self, move): if self.duck_phase: - return False + return False # only duck placements are legal during the duck phase if move.from_square == move.to_square: - return False + return False # from==to is reserved for duck placements, not a real move if self.duck_square is not None and move.to_square == self.duck_square: - return False + return False # can't capture/land on the duck self.add_duck_as_blocker() try: return super().is_pseudo_legal(move) @@ -1224,15 +1236,17 @@ def is_pseudo_legal(self, move): def is_legal(self, move): if self.duck_phase: + # Duck chess has no pins/checks, so pseudo-legal placements are legal. if move.from_square != move.to_square: - return False + return False if chess.BB_SQUARES[move.to_square] & self.occupied: - return False + return False if self.duck_square is not None and move.to_square == self.duck_square: - return False + return False return True return self.is_pseudo_legal(move) - + + #checks and checkmates are not relevant in duck chess, so we override these methods to always return False def is_check(self): return False @@ -1264,6 +1278,8 @@ def is_repetition(self, count=3): return False def is_variant_end(self): + # Duck chess has no check/checkmate; the game ends only when a king is + # actually captured which allows kings to be captured like any other piece. white_has_king = bool(self.kings & self.occupied_co[chess.WHITE]) black_has_king = bool(self.kings & self.occupied_co[chess.BLACK]) return not white_has_king or not black_has_king @@ -1294,6 +1310,7 @@ def parse_uci(self, uci): return super().parse_uci(uci) def _algebraic_without_suffix(self, move, *, long=False): + # Mirrors uci(): SAN for a duck placement is also "@". if move.from_square == move.to_square: return "@" + chess.SQUARE_NAMES[move.to_square] return super()._algebraic_without_suffix(move, long=long) @@ -1309,9 +1326,12 @@ def parse_san(self, san): return super().parse_san(san) def has_insufficient_material(self, color): + # A lone king can still checkmate except duck chess has no + # checkmate, only king capture, so material never forces a draw. return False def _attacked_for_king(self, path, occupied): + # No check/pins in duck chess, so castling through "attacked" squares is never restricted return False VARIANTS: List[Type[chess.Board]] = [ diff --git a/docs/variant.rst b/docs/variant.rst index 0a551587..d6581afe 100644 --- a/docs/variant.rst +++ b/docs/variant.rst @@ -28,6 +28,7 @@ Racing Kings :class:`chess.variant.RacingKingsBoard` racingkings Horde :class:`chess.variant.HordeBoard` horde Three-check :class:`chess.variant.ThreeCheckBoard` 3check Crazyhouse :class:`chess.variant.CrazyhouseBoard` crazyhouse +Duck Chess :class:`chess.variant.DuckChessBoard` duck ================ ========================================= ============= ============ .. autofunction:: chess.variant.find_variant @@ -85,6 +86,36 @@ Three-check Remaining checks until victory for each color. For example, ``board.remaining_checks[chess.WHITE] == 0`` implies that White has won. +Duck Chess +---------- + +A single "duck" occupies one square on the board at all times (after the +first move) and blocks movement onto or through that square for both +sides. After making a normal move, a player must also relocate the duck +before the turn passes to the opponent. There is no check, checkmate, or +draw by repetition or the fifty-move rule in this variant: a game ends +only when a king is captured. + +>>> board = chess.variant.DuckChessBoard() +>>> board.push_san("e4") +>>> board.push_san("@d4") + +.. autoclass:: chess.variant.DuckChessBoard + :members: generate_duck_placements + + .. py:attribute:: duck_square + :value: None + + The square currently occupied by the duck, or ``None`` if the duck + has not been placed yet. + + .. py:attribute:: duck_phase + :value: False + + ``True`` if a duck placement is currently pending, i.e. a normal + move was just made and the same player still needs to place the + duck before the turn passes. + UCI/XBoard ---------- diff --git a/test.py b/test.py index 2467b8f3..2eeb8386 100755 --- a/test.py +++ b/test.py @@ -5052,3 +5052,147 @@ def test_insufficient_material_never_true(self): board.set_piece_at(chess.E8, chess.Piece(chess.KING, chess.BLACK)) self.assertFalse(board.has_insufficient_material(chess.WHITE)) self.assertFalse(board.has_insufficient_material(chess.BLACK)) + + def test_fen_no_duck_yet(self): + board = chess.variant.DuckChessBoard() + fen = board.fen() + self.assertEqual(fen.split()[4], "-") + + def test_fen_duck_phase_prefix(self): + board = chess.variant.DuckChessBoard() + board.push(chess.Move.from_uci("e2e4")) + fen = board.fen() + self.assertEqual(fen.split()[4], "@-") + board.push(chess.Move(chess.E4, chess.E4)) + fen = board.fen() + self.assertEqual(fen.split()[4], "e4") + + def test_fen_round_trip_with_duck_placed(self): + board = chess.variant.DuckChessBoard() + board.push(chess.Move.from_uci("e2e4")) + board.push(chess.Move(chess.E4, chess.E4)) + board.push(chess.Move.from_uci("d7d5")) + fen = board.fen() + board2 = chess.variant.DuckChessBoard(fen) + self.assertEqual(board2.fen(), fen) + self.assertEqual(board2.duck_square, chess.E4) + self.assertTrue(board2.duck_phase) + + def test_set_fen_without_duck_field_defaults_to_none(self): + board = chess.variant.DuckChessBoard() + board.set_fen(chess.STARTING_FEN) + self.assertIsNone(board.duck_square) + self.assertFalse(board.duck_phase) + + def test_push_pop_restores_duck_state(self): + board = chess.variant.DuckChessBoard() + fen_before = board.fen() + board.push(chess.Move.from_uci("e2e4")) + board.push(chess.Move(chess.E4, chess.E4)) + board.push(chess.Move.from_uci("d7d5")) + board.pop() + self.assertEqual(board.duck_square, chess.E4) + self.assertFalse(board.duck_phase) + board.pop() + self.assertIsNone(board.duck_square) + self.assertTrue(board.duck_phase) + board.pop() + self.assertEqual(board.fen(), fen_before) + self.assertFalse(board.duck_phase) + + def test_duck_san_and_uci_notation(self): + board = chess.variant.DuckChessBoard() + move = chess.Move.from_uci("e2e4") + board.push(move) + duck_move = chess.Move(chess.D4, chess.D4) + self.assertEqual(board.uci(duck_move), "@d4") + self.assertEqual(board.san(duck_move), "@d4") + parsed = board.parse_san("@d4") + self.assertEqual(parsed, duck_move) + board.push(parsed) + self.assertEqual(board.duck_square, chess.D4) + + def test_push_san_full_turn(self): + board = chess.variant.DuckChessBoard() + board.push_san("e4") + board.push_san("@d4") + self.assertEqual(board.duck_square, chess.D4) + self.assertEqual(board.turn, chess.BLACK) + + def test_duck_old_square_becomes_free_again(self): + board = chess.variant.DuckChessBoard() + board.push_san("e4") + board.push_san("@e5") + board.push_san("Nf6") + legal_targets = {m.to_square for m in board.legal_moves} + self.assertNotIn(chess.E5, legal_targets) + board.push_san("@d5") + self.assertEqual(board.duck_square, chess.D5) + board.push_san("Nc3") + legal_targets = {m.to_square for m in board.legal_moves} + self.assertIn(chess.E5, legal_targets) + + def test_find_variant_and_aliases(self): + self.assertIs(chess.variant.find_variant("duck"), chess.variant.DuckChessBoard) + for alias in ["Duck", "Duck Chess", "Duckchess"]: + self.assertIs(chess.variant.find_variant(alias), chess.variant.DuckChessBoard) + + def test_variant_win_loss_perspective(self): + board = chess.variant.DuckChessBoard.empty() + board.set_piece_at(chess.E1, chess.Piece(chess.KING, chess.WHITE)) + board.turn = chess.WHITE + self.assertTrue(board.is_variant_end()) + self.assertTrue(board.is_variant_win()) + board.turn = chess.BLACK + self.assertTrue(board.is_variant_loss()) + + def test_copy_preserves_duck_state(self): + board = chess.variant.DuckChessBoard() + board.push_san("e4") + board.push_san("@d4") + board.push_san("d5") + copy = board.copy() + self.assertEqual(copy.duck_square, chess.D4) + self.assertTrue(copy.duck_phase) + self.assertEqual(copy.fen(), board.fen()) + copy.pop() + self.assertEqual(copy.duck_square, chess.D4) + self.assertFalse(copy.duck_phase) + # Original board is unaffected by mutating the copy's stack. + self.assertTrue(board.duck_phase) + + def test_root_preserves_duck_state(self): + board = chess.variant.DuckChessBoard() + fen_before = board.fen() + board.push_san("e4") + board.push_san("@d4") + board.push_san("d5") + root = board.root() + self.assertEqual(root.fen(), fen_before) + self.assertIsNone(root.duck_square) + self.assertFalse(root.duck_phase) + + def test_mirror_flips_duck_square(self): + board = chess.variant.DuckChessBoard() + board.push_san("e4") + board.push_san("@d4") + mirrored = board.mirror() + self.assertEqual(mirrored.duck_square, chess.D5) + + def test_equality_considers_duck_square(self): + board_a = chess.variant.DuckChessBoard() + board_a.push_san("e4") + board_a.push_san("@a3") + board_b = chess.variant.DuckChessBoard() + board_b.push_san("e4") + board_b.push_san("@h6") + self.assertNotEqual(board_a, board_b) + board_c = board_a.copy() + self.assertEqual(board_a, board_c) + + def test_status_ignores_missing_king_after_capture(self): + board = chess.variant.DuckChessBoard.empty() + board.set_piece_at(chess.E1, chess.Piece(chess.KING, chess.WHITE)) + board.turn = chess.BLACK + self.assertFalse(board.status() & chess.STATUS_NO_BLACK_KING) + self.assertFalse(board.status() & chess.STATUS_NO_WHITE_KING)