diff --git a/app/chess_sim/game_board.py b/app/chess_sim/game_board.py index d44bda6..3ca57b6 100644 --- a/app/chess_sim/game_board.py +++ b/app/chess_sim/game_board.py @@ -153,12 +153,17 @@ class ChessBoard: 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() or self.is_seventyfive_moves or self.is_fivefold_repetition: + 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 @@ -204,7 +209,7 @@ class ChessBoard: def init_default(cls) -> "ChessBoard": empty_board = [[BoardField() for _ in range(8)] for _ in range(8)] brd = cls( - empty_board, 0, [], empty_board + empty_board, 0, [], copy.deepcopy(empty_board) ) # place pawns @@ -232,8 +237,8 @@ class ChessBoard: for col, piece_type in enumerate(back_rank): brd.place(7, col, piece_type, Color.WHITE) - #set the inital board for later ref - brd.initial_board = brd.fields + # set the initial board for later ref (must be a copy, not a reference) + brd.initial_board = copy.deepcopy(brd.fields) return brd @@ -427,10 +432,15 @@ class ChessBoard: 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 + # opponent piece: capture is allowed moves.append(BoardMove(pos, BoardPos((tr, tc)))) + if non_sliding: + continue break @@ -739,3 +749,39 @@ class ChessBoard: bm = BoardMove(src, dst, move_type, promotion_piece=promo_piece) return self.make_move(bm, color) + 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.name == "CASTLING_KINGSIDE": + move_str += " (O-O)" + elif move.move_type.name == "CASTLING_QUEENSIDE": + move_str += " (O-O-O)" + elif move.move_type.name == "EN_PASSANT": + move_str += " (e.p.)" + elif move.promotion_piece: + move_str += f" (={move.promotion_piece.name[0]})" + + move_strings.append(move_str) + + print(f"\n{color.name} Legal Moves ({len(moves)} total):") + print("-" * 50) + + # Print in columns (6 moves per row) + 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)) + print("-" * 50 + "\n") + + diff --git a/app/sockets/socket.py b/app/sockets/socket.py index 10065a7..6496a62 100644 --- a/app/sockets/socket.py +++ b/app/sockets/socket.py @@ -6,7 +6,13 @@ from datetime import datetime from threading import Lock from typing import Optional -from chess_sim.game_board import ChessBoard, Outcome, Color +from app.chess_sim.game_board import ( + ChessBoard, + Outcome, + Color, + BoardMove, + PieceType, +) from flask_login import current_user from flask import request from flask_socketio import emit, join_room, leave_room @@ -27,7 +33,7 @@ class GameRoom: p2_user_id: Optional[int] = None p2_name: Optional[str] = None ready: dict[str, bool] = field(default_factory=dict) - board: ChessBoard = ChessBoard.init_default() + board: ChessBoard = field(default_factory=ChessBoard.init_default) color_by_sid: dict[str, str] = field(default_factory=dict) initial_ms: int = 600000 increment_ms: int = 0 @@ -160,15 +166,18 @@ def _emit_game_over(room: GameRoom, reason: str) -> None: p1_result = "draw" p2_result = "draw" + if outcome == Outcome.WHITE_WIN: + winner_color = "w" + else: + winner_color = "b" + if room.color_by_sid.get(room.p1_sid) == winner_color: + p1_result = "win" + p2_result = "loss" + else: + p1_result = "loss" + p2_result = "win" + - if outcome and outcome.winner is not None: - winner_color = "w" if outcome.winner == chess.WHITE else "b" - if room.color_by_sid.get(room.p1_sid) == winner_color: - p1_result = "win" - p2_result = "loss" - else: - p1_result = "loss" - p2_result = "win" emit("game_over", {"result": p1_result, "reason": reason}, to=room.p1_sid) if room.p2_sid: @@ -296,8 +305,7 @@ def on_disconnect(): room.p2_name = None room.ready.pop(sid, None) room.color_by_sid.pop(sid, None) - # initialize a new chessboard??? - room.board = chess.Board() + room.board = ChessBoard.init_default() room.ready[room.p1_sid] = False room.game_active = False room.completed = False @@ -307,6 +315,18 @@ def on_disconnect(): emit("p2_connected", {"p2_name": None, "ready": False}, to=other_sid) +def _make_room(sid: str, play_as: str, time_mode: str) -> GameRoom: + return GameRoom( + code="", # caller will fill in the actual code + p1_sid=sid, + p1_user_id=current_user.id, + p1_name=current_user.username, + p1_pref=play_as, + time_mode=time_mode, + ready={sid: False}, + ) + + @sIO.on("create_code_game") def on_create_code_game(payload): ok, err = validate_client_event("create_code_game", payload) @@ -324,14 +344,9 @@ def on_create_code_game(payload): _cleanup_room(existing.code) code = _new_code() - room = GameRoom( - code=code, - p1_sid=sid, - p1_name=current_user.username, - p1_pref=payload["play_as"], - time_mode=payload["time_mode"], - ready={sid: False}, - ) + room = _make_room(sid, payload["play_as"], payload["time_mode"]) + room.code = code # assign after generation so constructor stays pure + games_by_code[code] = room code_by_sid[sid] = code @@ -410,6 +425,31 @@ def on_user_ready(payload): _start_game_if_ready(room) +# helpers for converting client payloads to engine moves and back + +def _payload_to_move(payload: dict) -> BoardMove: + from_sq = ChessBoard.algebraic_to_pos(payload["from_square"]) + to_sq = ChessBoard.algebraic_to_pos(payload["to_square"]) + promotion = payload.get("promotion") + prom_piece: Optional[PieceType] = None + if promotion: + mapping = { + "q": PieceType.QUEEN, + "r": PieceType.ROOK, + "b": PieceType.BISHOP, + "n": PieceType.KNIGHT, + } + prom_piece = mapping.get(promotion.lower()) + if prom_piece is None: + raise ValueError("invalid promotion piece") + return BoardMove(from_sq, to_sq, promotion_piece=prom_piece) + + +def _move_to_san(move: BoardMove) -> str: + # todo can be improved, very simple + return f"{ChessBoard.pos_to_algebraic(move.m_from)}{ChessBoard.pos_to_algebraic(move.m_to)}" + + @sIO.on("move_request") def on_move_request(payload): ok, err = validate_client_event("move_request", payload) @@ -440,24 +480,28 @@ def on_move_request(payload): emit("move_reject", {"reason": "not your turn"}) return - uci = ( - f"{payload['from_square'].lower()}{payload['to_square'].lower()}" - f"{payload.get('promotion', '')}" - ) - try: - move = chess.Move.from_uci(uci) + move = _payload_to_move(payload) except ValueError: + print("rejecting move due to invalid move format") emit("move_reject", {"reason": "invalid move format"}, to=sid) return - if move not in room.board.legal_moves: + current_color = room.board.turn + legal = room.board.generate_moves(current_color) + if move not in legal: + print(f"current_color: {current_color}") + print("rejecting move due to illegal move") + print(f"move: {move}") + room.board.print_legal_moves(current_color) emit("move_reject", {"reason": "illegal move"}, to=sid) return mover_color = room.color_by_sid.get(sid) - san = room.board.san(move) - room.board.push(move) + san = _move_to_san(move) + # perform the move on our own engine + room.board.make_move(move, current_color) + room.move_history.append(san) if mover_color == "w": room.white_ms += room.increment_ms @@ -472,7 +516,7 @@ def on_move_request(payload): "promotion": payload.get("promotion"), "san": san, "time_left_ms": room.white_ms if mover_color == "w" else room.black_ms, - "fen": room.board.fen(), + "fen": room.board.to_fen(), **_clock_payload(room), } @@ -481,7 +525,9 @@ def on_move_request(payload): if other_sid: emit("user_move", move_payload, to=other_sid) - if room.board.is_game_over(claim_draw=True): + + if room.board.outcome() != Outcome.NOT_FINISHED: + print(f"ending a game due to outcome: { room.board.outcome()}") _emit_game_over(room, _game_over_reason(room.board))