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