Skip to content
Open
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
238 changes: 238 additions & 0 deletions chess/variant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1086,6 +1323,7 @@ def status(self) -> chess.Status:
HordeBoard,
ThreeCheckBoard,
CrazyhouseBoard,
DuckChessBoard
]


Expand Down
67 changes: 67 additions & 0 deletions test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Loading