from enum import Enum, auto from dataclasses import dataclass from typing import Iterator, Optional, List, Tuple import copy FILES = "abcdefgh" RANKS = "12345678" class PieceType(Enum): PAWN = auto() KNIGHT = auto() BISHOP = auto() ROOK = auto() QUEEN = auto() KING = auto() class Color(Enum): WHITE = auto() BLACK = auto() @property def opposite(self) -> "Color": if self == Color.WHITE: return Color.BLACK else: return Color.WHITE class Outcome(Enum): DRAW = auto() WHITE_WIN = auto() BLACK_WIN = auto() NOT_FINISHED = auto() @classmethod def from_color(cls, color: Color) -> "Outcome": if color == Color.WHITE: return Outcome.WHITE_WIN else: return Outcome.BLACK_WIN @dataclass class BoardPos: p: Tuple[int, int] def __str__(self) -> str: return f"({self.p[0]},{self.p[1]})" @property def x (self) -> int: return self.p[0] @property def y (self) -> int: return self.p[1] @dataclass class Piece: type: PieceType color: Color # uppercase = White, lowercase = Black def char(self) -> str: mapping = { PieceType.PAWN: "p", PieceType.KNIGHT: "n", PieceType.BISHOP: "b", PieceType.ROOK: "r", PieceType.QUEEN: "q", PieceType.KING: "k", } ch = mapping[self.type] return ch.upper() if self.color == Color.WHITE else ch def __str__(self) -> str: return self.char() def __repr__(self) -> str: return f"Piece({self.type.name},{self.color.name})" class MoveType(Enum): NORMAL = auto() CASTLING_KINGSIDE = auto() CASTLING_QUEENSIDE = auto() EN_PASSANT = auto() PROMOTION = auto() #todo: implement @dataclass class BoardMove: m_from: BoardPos m_to: BoardPos move_type: MoveType = MoveType.NORMAL promotion_piece: Optional[PieceType] = None #todo: implement and handle def __str__(self) -> str: return f"{self.m_from}->{self.m_to}" @dataclass class BoardField: piece: Optional[Piece] = None def __str__(self) -> str: return str(self.piece) if self.piece is not None else "." @dataclass class ChessBoard: fields: List[List[BoardField]] num_moves: int move_history: List[Tuple['BoardMove', Optional[Piece]]] initial_board: List[List[BoardField]] @property def turn(self) -> "Color": len_mv_history = len(self.move_history) if len_mv_history > 0: first_moved_piece_color = self.initial_move_color if len_mv_history % 2 == 0: return first_moved_piece_color else: return first_moved_piece_color.opposite else: return Color.WHITE @property def initial_move_color(self) -> Color: first_move = self.move_history[0] from_pos = first_move[0].m_from # look up what color the piece had that moved first # (odd solution but i think its quite fine) first_moved_field = self.initial_board[from_pos.x][from_pos.y] first_moved_piece_color = Color.WHITE if first_moved_field.piece is not None: first_moved_piece_color = first_moved_field.piece.color return first_moved_piece_color def is_checkmate(self) -> bool: cur_turn_color = self.turn return len(self.generate_moves(cur_turn_color)) == 0 and self.is_field_attacked(self.pos_of_king(cur_turn_color), cur_turn_color.opposite) def is_stalemate(self) -> bool: cur_turn_color = self.turn return len(self.generate_moves(cur_turn_color)) == 0 def is_seventyfive_moves(self) -> bool: return len(self.move_history) >= 75 def is_fivefold_repetition(self) -> bool: return self.highest_repetiton_amount() >= 5 def outcome(self) -> Outcome: if self.is_checkmate(): return Outcome.from_color(self.turn) if self.is_stalemate(): return Outcome.DRAW if self.is_seventyfive_moves(): return Outcome.DRAW if self.is_fivefold_repetition(): return Outcome.DRAW return Outcome.NOT_FINISHED def highest_repetiton_amount(self) -> int: if self.move_history: starting_color = self.initial_move_color else: starting_color = Color.WHITE dummy = ChessBoard( copy.deepcopy(self.initial_board), 0, [], copy.deepcopy(self.initial_board), ) color = starting_color states: list[str] = [] states.append(str(dummy)) for mv, _ in self.move_history: dummy.make_move(mv, color) color = color.opposite states.append(str(dummy)) counts: dict[str, int] = {} max_count = 0 for state in states: counts[state] = counts.get(state, 0) + 1 if counts[state] > max_count: max_count = counts[state] return max_count def place(self, row: int, col: int, piece_type: PieceType, color: Color): self.fields[row][col].piece = Piece(piece_type, color) # initialize default starting position @classmethod def init_default(cls) -> "ChessBoard": empty_board = [[BoardField() for _ in range(8)] for _ in range(8)] brd = cls( empty_board, 0, [], copy.deepcopy(empty_board) ) # place pawns for col in range(8): brd.place(1, col, PieceType.PAWN, Color.BLACK) brd.place(6, col, PieceType.PAWN, Color.WHITE) # back rank order back_rank = [ PieceType.ROOK, PieceType.KNIGHT, PieceType.BISHOP, PieceType.QUEEN, PieceType.KING, PieceType.BISHOP, PieceType.KNIGHT, PieceType.ROOK, ] # place black back rank for col, piece_type in enumerate(back_rank): brd.place(0, col, piece_type, Color.BLACK) # place white back rank for col, piece_type in enumerate(back_rank): brd.place(7, col, piece_type, Color.WHITE) # set the initial board for later ref (must be a copy, not a reference) brd.initial_board = copy.deepcopy(brd.fields) return brd # attempt to make move for color # returns true on success def make_move(self, move: BoardMove, color: Color) -> bool: captured = None row_height = 7 if color == Color.WHITE else 0 # validate move using legal moves for the player color legal = self.moves_unfiltered(color) if not any(m.m_from.p == move.m_from.p and m.m_to.p == move.m_to.p and m.move_type == move.move_type and m.promotion_piece == move.promotion_piece for m in legal): return False if move.move_type == MoveType.NORMAL: # perform move and record history for unmaking captured = self.move_piece(move.m_from, move.m_to).piece #castling is hardcoded elif move.move_type == MoveType.CASTLING_KINGSIDE: king_src = BoardPos((row_height, 4)) king_dest = BoardPos((row_height, 6)) ks_rook_src = BoardPos((row_height, 7)) ks_rook_dest = BoardPos((row_height, 5)) self.move_piece(king_src, king_dest) self.move_piece(ks_rook_src, ks_rook_dest) elif move.move_type == MoveType.CASTLING_QUEENSIDE: king_src = BoardPos((row_height, 4)) king_dest = BoardPos((row_height, 2)) ks_rook_src = BoardPos((row_height, 0)) ks_rook_dest = BoardPos((row_height, 3)) self.move_piece(king_src, king_dest) self.move_piece(ks_rook_src, ks_rook_dest) elif move.move_type == MoveType.EN_PASSANT: # direction the enemys pawn moved in en_passant_dir = 1 if color == Color.WHITE else -1 self.move_piece(move.m_from, move.m_to) affected_pawn_pos = BoardPos((move.m_to.x - en_passant_dir, move.m_to.y)) captured = self.get_field(affected_pawn_pos) self.fields[affected_pawn_pos.x][affected_pawn_pos.y].piece = None self.move_history.append((move, captured)) self.num_moves += 1 return True # moves one piece to another location # returns the dest field def move_piece(self, src_pos: BoardPos, dest_pos: BoardPos) -> BoardField: src_field = self.fields[src_pos.x][src_pos.y] dest_field = self.fields[dest_pos.x][dest_pos.y] moving = src_field.piece captured = BoardField(dest_field.piece) # move dest_field.piece = moving src_field.piece = None return captured def get_field(self, pos: BoardPos) -> Optional[Piece]: if not self.on_board(pos.x, pos.y): return None return self.fields[pos.x][pos.y].piece # returns false if there is no move to unmake def unmake_move(self) -> bool: if not self.move_history or len(self.move_history) == 0: return False move, captured = self.move_history.pop() sr, sc = move.m_from.p tr, tc = move.m_to.p moving_piece = self.fields[tr][tc].piece # restore self.fields[sr][sc].piece = moving_piece self.fields[tr][tc].piece = captured self.num_moves = max(0, self.num_moves - 1) return True # creates all theoretically possible moves which can be performed by the piece at a position def moves_unchecked(self, piece: Piece, pos: BoardPos) -> List[List[Tuple[int, int]]]: row, column = pos.p rays: List[List[Tuple[int, int]]] = [] if piece.type == PieceType.KING: king_offsets = [(1, 1), (1, 0), (0, 1), (-1, -1), (-1, 0), (0, -1), (1, -1), (-1, 1)] rays.append([(row + dr, column + dc) for dr, dc in king_offsets]) elif piece.type == PieceType.KNIGHT: knight_offsets = [ (2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2) ] rays.append([(row + dr, column + dc) for dr, dc in knight_offsets]) elif piece.type == PieceType.PAWN: # correct forward direction and start row depending on color if piece.color == Color.WHITE: forward = -1 start_row = 6 else: forward = 1 start_row = 1 forward_ray: List[Tuple[int, int]] = [] forward_ray.append((row + forward, column)) # if the pawn is at the start row, include the double-step if row == start_row: forward_ray.append((row + 2 * forward, column)) rays.append(forward_ray) # capture moves captures = [(row + forward, column - 1), (row + forward, column + 1)] rays.extend([[(rr, cc)] for rr, cc in captures]) elif piece.type == PieceType.BISHOP: directions = [(1, 1), (1, -1), (-1, 1), (-1, -1)] for dr, dc in directions: ray = [] for k in range(1, 8): ray.append((row + k * dr, column + k * dc)) rays.append(ray) elif piece.type == PieceType.ROOK: directions = [(1, 0), (-1, 0), (0, 1), (0, -1)] for dr, dc in directions: ray = [] for k in range(1, 8): ray.append((row + k * dr, column + k * dc)) rays.append(ray) elif piece.type == PieceType.QUEEN: directions = [ (1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1) ] for dr, dc in directions: ray = [] for k in range(1, 8): ray.append((row + k * dr, column + k * dc)) rays.append(ray) return rays def on_board(self, rr: int, cc: int) -> bool: return 0 <= rr < 8 and 0 <= cc < 8 # takes the color of the player whos possible moves will be returned def moves_unfiltered(self, color: Color) -> List[BoardMove]: moves: List[BoardMove] = [] for pos, piece in self.iter_pieces(): if piece.color != color: continue rays = self.moves_unchecked(piece, pos) for ray in rays: # detect pawn capture ray and dont allow movement in that ray without capturing is_pawn_capture_ray = ( piece.type == PieceType.PAWN and len(ray) == 1 and ray[0][1] != pos.y and abs(ray[0][0] - pos.x) == 1 ) # non sliding rays get treated individually non_sliding = piece.type in (PieceType.KNIGHT, PieceType.KING) or is_pawn_capture_ray for (tr, tc) in ray: if not self.on_board(tr, tc): if non_sliding: continue break target_field = self.fields[tr][tc] if target_field.piece is None: # empty target if is_pawn_capture_ray: move_history_len = len(self.move_history) # pawn can only move diagonally into an empty square during en passant, check for that en_passant_dir = 1 if color == Color.WHITE else -1 if move_history_len > 0: # check if there is a pawn in the correct position if self.get_field(BoardPos((tr, tc - en_passant_dir))) == Piece( PieceType.PAWN, color.opposite, ): # check if the last move moved the pawn there from the starting square last_move = self.move_history[-1][0] # final move, not past-the-end if last_move.m_from == BoardPos((tr, tc + en_passant_dir)) and last_move.m_to == BoardPos((tr, tc - en_passant_dir)): moves.append(BoardMove(pos, BoardPos((tr, tc)))) break moves.append(BoardMove(pos, BoardPos((tr, tc)))) continue else: # occupied if target_field.piece.color == piece.color: if non_sliding: continue # for sliding pieces a blocker stops further squares break else: # opponent piece: capture is allowed moves.append(BoardMove(pos, BoardPos((tr, tc)))) if non_sliding: continue break # manually adding castling row_height = 7 if color == Color.WHITE else 0 expected_king_pos = BoardPos((row_height, 4)) if self.has_piece_moved(expected_king_pos): return moves expected_lhs_rook_pos = BoardPos((row_height, 0)) expected_rhs_rook_pos = BoardPos((row_height, 7)) # squares that must be empty between rook and king lhs_pieces_inbetween = [BoardPos((row_height, i)) for i in range(1, 4)] # cols 1,2,3 rhs_pieces_inbetween = [BoardPos((row_height, i)) for i in range(5, 7)] # cols 5,6 # squares that must not be attacked by the opponent lhs_fields_attacked = [BoardPos((row_height, i)) for i in (4, 3, 2)] rhs_fields_attacked = [BoardPos((row_height, i)) for i in (4, 5, 6)] # queenside if (not self.has_piece_moved(expected_lhs_rook_pos) and self.are_pieces_none(lhs_pieces_inbetween) and not self.are_fields_attacked(lhs_fields_attacked, color.opposite)): king_lhs_dest = BoardPos((row_height, 2)) moves.append(BoardMove(expected_king_pos, king_lhs_dest, MoveType.CASTLING_QUEENSIDE)) # kingside if (not self.has_piece_moved(expected_rhs_rook_pos) and self.are_pieces_none(rhs_pieces_inbetween) and not self.are_fields_attacked(rhs_fields_attacked, color.opposite)): king_rhs_dest = BoardPos((row_height, 6)) moves.append(BoardMove(expected_king_pos, king_rhs_dest, MoveType.CASTLING_KINGSIDE)) return moves def has_piece_moved(self, pos: BoardPos) -> bool: for move in self.move_history: if move[0].m_from == pos: return True elif move[0].m_to == pos: return True return False def are_pieces_none(self, positions: List[BoardPos]): are_gone = True for pos in positions: if not self.is_piece_none(pos): are_gone = False return are_gone def is_piece_none(self, pos: BoardPos) -> bool: piece = self.fields[pos.x][pos.y].piece if piece is None: return True else: return False def is_piece_of_type(self, pos: BoardPos, piece_type: PieceType) -> bool: piece = self.fields[pos.x][pos.y].piece if piece is None: return False return piece.type == piece_type # made by chatgpt def iter_pieces(self) -> Iterator[Tuple[BoardPos, Piece]]: for row_idx, row in enumerate(self.fields): for col_idx, field in enumerate(row): if field.piece is not None: yield BoardPos((row_idx, col_idx)), field.piece def pos_of_king(self, color: Color) -> BoardPos: for pos, piece in self.iter_pieces(): if piece.type == PieceType.KING and piece.color == color: return pos raise ValueError("player has no king") # returns true if one field is actively being attacked def are_fields_attacked(self, fields: List[BoardPos], attacker_color: Color) -> bool: for field in fields: if self.is_field_attacked(field, attacker_color): return True return False # checks if the field is being actively attacked by any of the pieces of the attacker color # this "complicated" implementation was necessary to not rely on recursive calls def is_field_attacked(self, pos: BoardPos, attacker_color: Color) -> bool: # iterate opponent pieces and check whether they attack for attacker_pos, attacker in self.iter_pieces(): if attacker.color != attacker_color: continue rays = self.moves_unchecked(attacker, attacker_pos) if attacker.type == PieceType.PAWN: # pawn attacks are the pawn capture rays only forward = -1 if attacker.color == Color.WHITE else 1 for dc in (-1, 1): rr = attacker_pos.x + forward cc = attacker_pos.y + dc if not self.on_board(rr, cc): continue if BoardPos((rr, cc)) == pos: return True continue if attacker.type in (PieceType.KNIGHT, PieceType.KING): # non-sliding for (rr, cc) in rays[0]: if not self.on_board(rr, cc): continue if BoardPos((rr, cc)) == pos: return True continue # sliding pieces for ray in rays: for (rr, cc) in ray: if not self.on_board(rr, cc): break if BoardPos((rr, cc)) == pos: return True # stop at first occupied square (blocker) if self.fields[rr][cc].piece is not None: break return False # if a player does not have any moves, it has lost def generate_moves(self, color: Color) -> List[BoardMove]: # only moves after which the king is not in check are allowed # todo: the current method of checking what move will resolve the check is based on bruteforcing # maybe there is a better way of doing it, but for now this should suffice us_moves_wo_check = [] for move in self.moves_unfiltered(color): # do a move and then check, if the king is still in check # if it isnt, add the move to the possibles ones if not self.make_move(move, color): raise ValueError(f"self.moves_basic created a move, which cannot be done (hopefully unreachable). move: {move}") all_basic_enemy_moves = self.moves_unfiltered(color.opposite) king_pos = self.pos_of_king(color) king_in_check = False for mv in all_basic_enemy_moves: if mv.m_to == king_pos: king_in_check = True if not king_in_check: us_moves_wo_check.append(move) if not self.unmake_move(): raise ValueError("failed to unmake move, shouldnt be an issue here") # check if our move lands directly on the enemies king, if it does, its illigal us_moves_rule_compliant = [] king_pos = self.pos_of_king(color.opposite) for move in us_moves_wo_check: if move.m_to != king_pos: us_moves_rule_compliant.append(move) return us_moves_rule_compliant def __str__(self) -> str: """ example string repr of starting pos: rnbqkbnr pppppppp ........ ........ ........ ........ PPPPPPPP RNBQKBNR """ lines: List[str] = [] for r in range(8): row_chars = [str(self.fields[r][c]) for c in range(8)] lines.append("".join(row_chars)) return "\n".join(lines) # https://en.wikipedia.org/wiki/Algebraic_notation_(chess) @staticmethod def pos_to_algebraic(pos: BoardPos) -> str: file = FILES[pos.y] rank = RANKS[7 - pos.x] return file + rank @staticmethod def algebraic_to_pos(s: str) -> BoardPos: if len(s) != 2: raise ValueError("invalid algebraic square") file_char = s[0].lower() rank_char = s[1] if file_char not in FILES or rank_char not in RANKS: raise ValueError("invalid algebraic square") col = FILES.index(file_char) row = 7 - RANKS.index(rank_char) return BoardPos((row, col)) # this function made by chatgpt def to_fen(self) -> str: # piece placement ranks: List[str] = [] for r in range(8): empty = 0 row_str = "" for c in range(8): p = self.fields[r][c].piece if p is None: empty += 1 else: if empty > 0: row_str += str(empty) empty = 0 row_str += p.char() if empty > 0: row_str += str(empty) ranks.append(row_str) placement = "/".join(ranks) active = 'w' if (self.num_moves % 2) == 0 else 'b' # castling availability castling = '' # white K/Q if (self.fields[7][4].piece is not None and self.fields[7][4].piece.type == PieceType.KING and self.fields[7][4].piece.color == Color.WHITE and self.fields[7][7].piece is not None and self.fields[7][7].piece.type == PieceType.ROOK and self.fields[7][7].piece.color == Color.WHITE and not self.has_piece_moved(BoardPos((7,4))) and not self.has_piece_moved(BoardPos((7,7)))): castling += 'K' if (self.fields[7][4].piece is not None and self.fields[7][4].piece.type == PieceType.KING and self.fields[7][4].piece.color == Color.WHITE and self.fields[7][0].piece is not None and self.fields[7][0].piece.type == PieceType.ROOK and self.fields[7][0].piece.color == Color.WHITE and not self.has_piece_moved(BoardPos((7,4))) and not self.has_piece_moved(BoardPos((7,0)))): castling += 'Q' # black k/q if (self.fields[0][4].piece is not None and self.fields[0][4].piece.type == PieceType.KING and self.fields[0][4].piece.color == Color.BLACK and self.fields[0][7].piece is not None and self.fields[0][7].piece.type == PieceType.ROOK and self.fields[0][7].piece.color == Color.BLACK and not self.has_piece_moved(BoardPos((0,4))) and not self.has_piece_moved(BoardPos((0,7)))): castling += 'k' if (self.fields[0][4].piece is not None and self.fields[0][4].piece.type == PieceType.KING and self.fields[0][4].piece.color == Color.BLACK and self.fields[0][0].piece is not None and self.fields[0][0].piece.type == PieceType.ROOK and self.fields[0][0].piece.color == Color.BLACK and not self.has_piece_moved(BoardPos((0,4))) and not self.has_piece_moved(BoardPos((0,0)))): castling += 'q' if castling == '': castling = '-' # en passant target square: if last move was a pawn double-step, set the square behind it ep = '-' if len(self.move_history) > 0: last_move = self.move_history[-1][0] moved_piece = self.get_field(last_move.m_to) if moved_piece is not None and moved_piece.type == PieceType.PAWN and abs(last_move.m_from.x - last_move.m_to.x) == 2: passed_row = (last_move.m_from.x + last_move.m_to.x) // 2 passed_col = last_move.m_from.y ep = self.pos_to_algebraic(BoardPos((passed_row, passed_col))) # todo half moves not yet tracked halfmove = 0 # fullmove number fullmove = (self.num_moves // 2) + 1 return f"{placement} {active} {castling} {ep} {halfmove} {fullmove}" # https://backscattering.de/chess/uci/ def make_move_uci(self, uci: str, color: Color) -> bool: if uci == '0000': return False if len(uci) < 4: return False src = self.algebraic_to_pos(uci[0:2]) dst = self.algebraic_to_pos(uci[2:4]) promo_piece = None if len(uci) == 5: pc = uci[4].lower() mapping = {'q': PieceType.QUEEN, 'r': PieceType.ROOK, 'b': PieceType.BISHOP, 'n': PieceType.KNIGHT} promo_piece = mapping.get(pc, None) moving = self.get_field(src) if moving is None or moving.color != color: return False # detect castling by king moving two files move_type = MoveType.NORMAL if moving.type == PieceType.KING and abs(src.y - dst.y) == 2: move_type = MoveType.CASTLING_KINGSIDE if dst.y > src.y else MoveType.CASTLING_QUEENSIDE else: # en passant detection: pawn moves diagonally to empty square if moving.type == PieceType.PAWN and src.y != dst.y and self.get_field(dst) is None: if len(self.move_history) > 0: # make sure the last move was a pawn move for this en passant to be possible last = self.move_history[-1][0] if last.m_from.x - last.m_to.x == 2 and last.m_to.x == src.x and last.m_to.y == dst.y: move_type = MoveType.EN_PASSANT # promotion if promo_piece is not None: move_type = MoveType.PROMOTION bm = BoardMove(src, dst, move_type, promotion_piece=promo_piece) return self.make_move(bm, color) # debugging purposes only def print_legal_moves(self, color: Color) -> None: moves = self.generate_moves(color) if not moves: print(f"{color.name}: no legal moves") return move_strings = [] for move in moves: from_sq = self.pos_to_algebraic(move.m_from) to_sq = self.pos_to_algebraic(move.m_to) move_str = f"{from_sq}-{to_sq}" # Add move type notation if move.move_type == MoveType.CASTLING_KINGSIDE: move_str += " (O-O)" elif move.move_type == MoveType.CASTLING_QUEENSIDE: move_str += " (O-O-O)" elif move.move_type.name == MoveType.EN_PASSANT: move_str += " (e.p.)" move_strings.append(move_str) print(f"\n{color.name} legal Moves ({len(moves)} total):") # Print in columns moves_per_row = 6 for i in range(0, len(move_strings), moves_per_row): row = move_strings[i:i + moves_per_row] print(" " + " | ".join(f"{m:12}" for m in row))