diff --git a/app/chess_sim/test.py b/app/chess_sim/test.py index 6a7f614..c901463 100644 --- a/app/chess_sim/test.py +++ b/app/chess_sim/test.py @@ -69,7 +69,7 @@ class MoveType(Enum): NORMAL = auto() CASTLING_KINGSIDE = auto() CASTLING_QUEENSIDE = auto() - EN_PASSANT = auto() #todo: implement + EN_PASSANT = auto() PROMOTION = auto() #todo: implement @dataclass @@ -142,7 +142,7 @@ class ChessBoard: row_height = 7 if color == Color.WHITE else 0 # validate move using legal moves for the player color - legal = self.moves_basic(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: @@ -165,6 +165,14 @@ class ChessBoard: 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 @@ -184,7 +192,10 @@ class ChessBoard: 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: @@ -265,7 +276,7 @@ class ChessBoard: return 0 <= rr < 8 and 0 <= cc < 8 # takes the color of the player whos possible moves will be returned - def moves_basic(self, color: Color) -> List[BoardMove]: + def moves_unfiltered(self, color: Color) -> List[BoardMove]: moves: List[BoardMove] = [] for pos, piece in self.iter_pieces(): if piece.color != color: @@ -293,7 +304,20 @@ class ChessBoard: if target_field.piece is None: # empty target if is_pawn_capture_ray: - # pawn cant move diagonally into empty square + 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 @@ -439,13 +463,13 @@ class ChessBoard: # maybe there is a better way of doing it, but for now this should suffice us_moves_wo_check = [] - for move in self.moves_basic(color): + 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_basic(color.opposite) + 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: @@ -486,6 +510,125 @@ class ChessBoard: 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 = chr(ord('a') + pos.y) + rank = str(8 - pos.x) + return f"{file}{rank}" + + @staticmethod + def algebraic_to_pos(s: str) -> BoardPos: + if len(s) != 2: + raise ValueError("invalid algebraic square") + col = ord(s[0].lower()) - ord('a') + row = 8 - int(s[1]) + 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) + + def play_random_game(board: Optional[ChessBoard] = None, max_moves: int = 400, verbose: bool = False) -> Tuple[ ChessBoard, int]: if board is None: @@ -514,13 +657,9 @@ def play_random_game(board: Optional[ChessBoard] = None, max_moves: int = 400, v ok = board.make_move(move, current) # debug assert if not ok: - # if an unexpected failure happens, break and treat as draw if verbose: print(f"make_move returned False for move {move} by {current}") return board, moves_played - if move.move_type == MoveType.CASTLING_KINGSIDE or move.move_type == MoveType.CASTLING_QUEENSIDE: - print("hell yeah we castled") - raise ValueError("casling") moves_played += 1 if verbose: print(f"{moves_played:03d}: {current.name} played {move}\nboard:\n{board}") @@ -611,13 +750,13 @@ def main(): raise AssertionError(f"initial board mismatch:\nexpected:\n{expected_start}\n\nactual:\n{actual_start}") # test num moves - mvs = default_brd.moves_basic(Color.BLACK) + mvs = default_brd.moves_unfiltered(Color.BLACK) expected_move_count = 20 # 16 pawn moves and 4 knight if len(mvs) != expected_move_count: raise AssertionError(f"initial move count for black mismatch: expected {expected_move_count}, got {len(mvs)}") - mvs = default_brd.moves_basic(Color.WHITE) + mvs = default_brd.moves_unfiltered(Color.WHITE) expected_move_count = 20 # 16 pawn moves and 4 knight if len(mvs) != expected_move_count: raise AssertionError(f"Initial move count for white mismatch: expected {expected_move_count}, got {len(mvs)}")