diff --git a/app/chess_sim/game_board.py b/app/chess_sim/game_board.py new file mode 100644 index 0000000..0a1321d --- /dev/null +++ b/app/chess_sim/game_board.py @@ -0,0 +1,641 @@ +from enum import Enum, auto +from dataclasses import dataclass +from typing import Iterator, Optional, List, Tuple +import random + +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 + + +@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]]] + + 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": + brd = cls( + [[BoardField() for _ in range(8)] for _ in range(8)], 0, [] + ) + + # 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) + + 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[move_history_len][0] + + 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: + break + else: + # opponent piece + moves.append(BoardMove(pos, BoardPos((tr, tc)))) + 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 + + 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)) + + # https://www.chess.com/de/terms/forsyth-edwards-notation-fen + 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) + diff --git a/app/chess_sim/test.py b/app/chess_sim/test.py index 4a7b9d7..8ac6188 100644 --- a/app/chess_sim/test.py +++ b/app/chess_sim/test.py @@ -2,643 +2,7 @@ from enum import Enum, auto from dataclasses import dataclass from typing import Iterator, Optional, List, Tuple import random - -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 - - -@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]]] - - 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": - brd = cls( - [[BoardField() for _ in range(8)] for _ in range(8)], 0, [] - ) - - # 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) - - 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[move_history_len][0] - - 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: - break - else: - # opponent piece - moves.append(BoardMove(pos, BoardPos((tr, tc)))) - 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 - - 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)) - - # https://www.chess.com/de/terms/forsyth-edwards-notation-fen - 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) - +from game_board import ChessBoard, MoveType, Color, PieceType, BoardMove, BoardPos def play_random_game(board: Optional[ChessBoard] = None, max_moves: int = 400, verbose: bool = False) -> Tuple[ ChessBoard, int]: