diff --git a/app/chess_sim/game_board.py b/app/chess_sim/game_board.py index 0a1321d..d44bda6 100644 --- a/app/chess_sim/game_board.py +++ b/app/chess_sim/game_board.py @@ -1,11 +1,13 @@ from enum import Enum, auto from dataclasses import dataclass from typing import Iterator, Optional, List, Tuple -import random - +import copy FILES = "abcdefgh" RANKS = "12345678" + + + class PieceType(Enum): PAWN = auto() KNIGHT = auto() @@ -26,6 +28,19 @@ class Color(Enum): 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: @@ -99,6 +114,87 @@ 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() or self.is_seventyfive_moves or 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) @@ -106,8 +202,9 @@ class ChessBoard: # initialize default starting position @classmethod def init_default(cls) -> "ChessBoard": + empty_board = [[BoardField() for _ in range(8)] for _ in range(8)] brd = cls( - [[BoardField() for _ in range(8)] for _ in range(8)], 0, [] + empty_board, 0, [], empty_board ) # place pawns @@ -135,6 +232,9 @@ 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 + return brd # attempt to make move for color @@ -317,7 +417,7 @@ class ChessBoard: color.opposite, ): # check if the last move moved the pawn there from the starting square - last_move = self.move_history[move_history_len][0] + 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)))) diff --git a/app/chess_sim/test.py b/app/chess_sim/test.py index 8ac6188..0d963d0 100644 --- a/app/chess_sim/test.py +++ b/app/chess_sim/test.py @@ -65,6 +65,7 @@ def run_random_games(n: int = 20, max_moves: int = 200, verbose: bool = False): def test_castling_kingside_both_sides(): board = ChessBoard.init_default() + # rewrite with boardmove from string seq = [ (BoardMove(BoardPos((7, 6)), BoardPos((5, 5))), Color.WHITE), (BoardMove(BoardPos((0, 1)), BoardPos((2, 2))), Color.BLACK), @@ -139,6 +140,32 @@ def main(): test_castling_kingside_both_sides() run_random_games(verbose=False) + board = ChessBoard.init_default() + # helper to create boardmove from two-square string + def bm(s): + return BoardMove( + ChessBoard.algebraic_to_pos(s[0:2]), + ChessBoard.algebraic_to_pos(s[2:4]) + ) + + # perform four cycles of the knight shuffle + # Nf3-Nf6-Ng1-Ng8 and returns to the starting position. + seq = [] + for _ in range(4): + seq.extend([ + (bm("g1f3"), Color.WHITE), + (bm("g8f6"), Color.BLACK), + (bm("f3g1"), Color.WHITE), + (bm("f6g8"), Color.BLACK), + ]) + for move, color in seq: + ok = board.make_move(move, color) + assert ok, "repetition test move failed" + + rep = board.highest_repetiton_amount() + assert rep >= 5, f"expected at least 5 repetitions, got {rep}" + assert board.is_fivefold_repetition(), "board should report fivefold repetition" + print(f"all tests passed") diff --git a/app/sockets/socket.py b/app/sockets/socket.py index e13bbd0..bbd708b 100644 --- a/app/sockets/socket.py +++ b/app/sockets/socket.py @@ -2,16 +2,15 @@ import random import string import time from dataclasses import dataclass, field -from datetime import datetime from threading import Lock from typing import Optional -import chess #todo: replace with own chess logic implementation to remove dependency +import chess +from chess_sim.game_board import ChessBoard, Color, Outcome from flask_login import current_user from flask import request from flask_socketio import emit, join_room, leave_room from app import sIO -from app.models.game import save_finished_game from .types import validate_client_event @@ -19,15 +18,13 @@ from .types import validate_client_event class GameRoom: code: str p1_sid: str - p1_user_id: int p1_name: str p1_pref: str time_mode: str p2_sid: Optional[str] = None - p2_user_id: Optional[int] = None p2_name: Optional[str] = None ready: dict[str, bool] = field(default_factory=dict) - board: chess.Board = field(default_factory=chess.Board) + board: ChessBoard = ChessBoard.init_default() color_by_sid: dict[str, str] = field(default_factory=dict) initial_ms: int = 600000 increment_ms: int = 0 @@ -36,10 +33,6 @@ class GameRoom: active_since: Optional[float] = None game_active: bool = False completed: bool = False - move_history: list[str] = field(default_factory=list) - started_at: Optional[str] = None - ended_at: Optional[str] = None - saved_game_id: Optional[int] = None games_by_code: dict[str, GameRoom] = {} @@ -74,10 +67,6 @@ def _parse_time_mode(time_mode: str) -> tuple[int, int]: return minutes * 60 * 1000, increment_seconds * 1000 -def _timestamp_now() -> str: - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - def _room_for_sid(sid: str) -> Optional[GameRoom]: code = code_by_sid.get(sid) if not code: @@ -93,14 +82,14 @@ def _sid_for_color(room: GameRoom, color: str) -> Optional[str]: def _turn_sid(room: GameRoom) -> Optional[str]: - return _sid_for_color(room, "w" if room.board.turn == chess.WHITE else "b") + return _sid_for_color(room, "w" if room.board.turn == Color.WHITE else "b") def _clock_payload(room: GameRoom) -> dict: return { "white_time_left_ms": max(0, room.white_ms), "black_time_left_ms": max(0, room.black_ms), - "turn": "w" if room.board.turn == chess.WHITE else "b", + "turn": "w" if room.board.turn == Color.WHITE else "b", } @@ -113,7 +102,7 @@ def _apply_elapsed(room: GameRoom) -> Optional[str]: if elapsed_ms <= 0: return None - active_color = "w" if room.board.turn == chess.WHITE else "b" + active_color = "w" if room.board.turn == Color.WHITE else "b" if active_color == "w": room.white_ms = max(0, room.white_ms - elapsed_ms) else: @@ -137,13 +126,14 @@ def _cleanup_room(code: str) -> None: code_by_sid.pop(room.p2_sid, None) -def _game_over_reason(board: chess.Board) -> str: +def _game_over_reason(board: ChessBoard) -> str: if board.is_checkmate(): return "checkmate" if board.is_stalemate(): return "stalemate" - if board.is_insufficient_material(): - return "insufficient material" + # todo introduce game over reason insufficient material + # if board.is_insufficient_material(): + # return "insufficient material" if board.is_seventyfive_moves(): return "75-move rule" if board.is_fivefold_repetition(): @@ -151,82 +141,27 @@ def _game_over_reason(board: chess.Board) -> str: return "game over" -def _termination_key(reason: str) -> str: - if reason == "checkmate": - return "checkmate" - if reason == "resignation": - return "resignation" - if reason == "timeout": - return "timeout" - if reason in { - "stalemate", - "insufficient material", - "75-move rule", - "fivefold repetition", - "draw agreed", - }: - return "draw" - return "other" - - -def _winner_color_value(room: GameRoom, winner_sid: Optional[str]) -> Optional[str]: - if winner_sid is None: - return "draw" - - winner = room.color_by_sid.get(winner_sid) - if winner == "w": - return "white" - if winner == "b": - return "black" - return None - - -def _result_for_sid(room: GameRoom, sid: str, winner_color: Optional[str]) -> str: - if winner_color == "draw" or winner_color is None: - return "draw" - - player_color = room.color_by_sid.get(sid) - return "win" if player_color == winner_color else "loss" - - -def _save_completed_game(room: GameRoom, reason: str, winner_color: Optional[str]) -> Optional[int]: - if room.saved_game_id is not None: - return room.saved_game_id - if not room.started_at or room.p2_user_id is None: - return None - - room.ended_at = room.ended_at or _timestamp_now() - room.saved_game_id = save_finished_game( - white_player_id=room.p1_user_id if room.color_by_sid.get(room.p1_sid) == "w" else room.p2_user_id, - black_player_id=room.p1_user_id if room.color_by_sid.get(room.p1_sid) == "b" else room.p2_user_id, - final_fen=room.board.fen(), - termination=_termination_key(reason), - termination_detail=reason, - winner_color=winner_color, - move_history=room.move_history, - time_mode=room.time_mode, - started_at=room.started_at, - ended_at=room.ended_at, - ) - return room.saved_game_id - - def _emit_game_over(room: GameRoom, reason: str) -> None: room.completed = True room.game_active = False - outcome = room.board.outcome(claim_draw=True) - winner_color = "draw" + outcome = room.board.outcome() + p1_result = "draw" + p2_result = "draw" + + 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" - game_id = _save_completed_game(room, reason, "white" if winner_color == "w" else "black" if winner_color == "b" else "draw") - p1_result = _result_for_sid(room, room.p1_sid, winner_color) - - emit("game_over", {"result": p1_result, "reason": reason, "game_id": game_id}, to=room.p1_sid) + emit("game_over", {"result": p1_result, "reason": reason}, to=room.p1_sid) if room.p2_sid: - p2_result = _result_for_sid(room, room.p2_sid, winner_color) - emit("game_over", {"result": p2_result, "reason": reason, "game_id": game_id}, to=room.p2_sid) + emit("game_over", {"result": p2_result, "reason": reason}, to=room.p2_sid) def _emit_timeout(room: GameRoom, timed_out_color: str) -> None: @@ -234,18 +169,13 @@ def _emit_timeout(room: GameRoom, timed_out_color: str) -> None: room.game_active = False reason = "timeout" - winner_color = "b" if timed_out_color == "w" else "w" - game_id = _save_completed_game( - room, - reason, - "white" if winner_color == "w" else "black", - ) - p1_result = _result_for_sid(room, room.p1_sid, winner_color) + p1_color = room.color_by_sid.get(room.p1_sid) + p1_result = "loss" if p1_color == timed_out_color else "win" + p2_result = "win" if p1_result == "loss" else "loss" - sIO.emit("game_over", {"result": p1_result, "reason": reason, "game_id": game_id}, to=room.p1_sid) + sIO.emit("game_over", {"result": p1_result, "reason": reason}, to=room.p1_sid) if room.p2_sid: - p2_result = _result_for_sid(room, room.p2_sid, winner_color) - sIO.emit("game_over", {"result": p2_result, "reason": reason, "game_id": game_id}, to=room.p2_sid) + sIO.emit("game_over", {"result": p2_result, "reason": reason}, to=room.p2_sid) def _start_game_if_ready(room: GameRoom) -> None: @@ -254,7 +184,7 @@ def _start_game_if_ready(room: GameRoom) -> None: if not room.ready.get(room.p1_sid) or not room.ready.get(room.p2_sid): return - room.board = chess.Board() + room.board = ChessBoard.init_default() if room.p1_pref == "w": p1_color = "w" elif room.p1_pref == "b": @@ -271,22 +201,18 @@ def _start_game_if_ready(room: GameRoom) -> None: room.active_since = time.monotonic() room.game_active = True room.completed = False - room.move_history = [] - room.started_at = _timestamp_now() - room.ended_at = None - room.saved_game_id = None p1_payload = { "play_as": p1_color, "time_left_ms": room.white_ms if p1_color == "w" else room.black_ms, - "fen": room.board.fen(), + "fen": room.board.to_fen(), "opponent": room.p2_name, **_clock_payload(room), } p2_payload = { "play_as": p2_color, "time_left_ms": room.white_ms if p2_color == "w" else room.black_ms, - "fen": room.board.fen(), + "fen": room.board.to_fen(), "opponent": room.p1_name, **_clock_payload(room), } @@ -349,66 +275,22 @@ def on_disconnect(): leave_room(code) code_by_sid.pop(sid, None) - if room.completed: - _cleanup_room(code) - return - if sid == room.p1_sid: if room.p2_sid: - if room.started_at and room.color_by_sid and not room.completed: - room.completed = True - room.game_active = False - game_id = _save_completed_game( - room, - "opponent disconnected", - _winner_color_value(room, room.p2_sid), - ) - emit( - "game_over", - { - "result": "win", - "reason": "opponent disconnected", - "game_id": game_id, - }, - to=room.p2_sid, - ) + emit("game_over", {"result": "win", "reason": "opponent disconnected"}, to=room.p2_sid) _cleanup_room(code) return - if room.started_at and room.color_by_sid and not room.completed: - room.completed = True - room.game_active = False - game_id = _save_completed_game( - room, - "opponent disconnected", - _winner_color_value(room, room.p1_sid), - ) - _cleanup_room(code) - emit( - "game_over", - { - "result": "win", - "reason": "opponent disconnected", - "game_id": game_id, - }, - to=room.p1_sid, - ) - return - room.p2_sid = None - room.p2_user_id = None 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.ready[room.p1_sid] = False room.game_active = False room.completed = False room.active_since = None - room.move_history = [] - room.started_at = None - room.ended_at = None - room.saved_game_id = None if other_sid: emit("p2_connected", {"p2_name": None, "ready": False}, to=other_sid) @@ -434,7 +316,6 @@ def on_create_code_game(payload): room = GameRoom( code=code, p1_sid=sid, - p1_user_id=current_user.id, p1_name=current_user.username, p1_pref=payload["play_as"], time_mode=payload["time_mode"], @@ -478,7 +359,6 @@ def on_join_code_game(payload): _cleanup_room(previous.code) room.p2_sid = sid - room.p2_user_id = current_user.id room.p2_name = current_user.username room.ready.setdefault(room.p1_sid, False) room.ready[sid] = False @@ -566,7 +446,6 @@ def on_move_request(payload): mover_color = room.color_by_sid.get(sid) san = room.board.san(move) - room.move_history.append(san) room.board.push(move) if mover_color == "w": @@ -616,11 +495,9 @@ def on_request_resign(payload): reason = "resignation" other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid - winner_sid = other_sid if other_sid else None - game_id = _save_completed_game(room, reason, _winner_color_value(room, winner_sid)) - emit("game_over", {"result": "loss", "reason": reason, "game_id": game_id}, to=sid) + emit("game_over", {"result": "loss", "reason": reason}, to=sid) if other_sid: - emit("game_over", {"result": "win", "reason": reason, "game_id": game_id}, to=other_sid) + emit("game_over", {"result": "win", "reason": reason}, to=other_sid) @sIO.on("request_draw") @@ -642,8 +519,7 @@ def on_request_draw(payload): if payload.get("accepted") is True: room.completed = True room.game_active = False - game_id = _save_completed_game(room, "draw agreed", "draw") - emit("game_over", {"result": "draw", "reason": "draw agreed", "game_id": game_id}, to=room.code) + emit("game_over", {"result": "draw", "reason": "draw agreed"}, to=room.code) return other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid diff --git a/app/static/app.css b/app/static/app.css index 3301c48..df34f94 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -79,6 +79,26 @@ body { border-radius: 4px; } +.quick-join-form { + display: flex; + align-items: center; + gap: 8px; +} + +.quick-join-form input { + width: 116px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 9px 10px; + font: inherit; + text-transform: uppercase; + background: var(--surface); +} + +.home-join { + margin-top: 10px; +} + .page-wrap { max-width: 1000px; margin: 0 auto; @@ -329,6 +349,17 @@ h3 { text-align: center; } + .quick-join-form { + flex: 1; + min-width: 0; + } + + .quick-join-form input { + flex: 1; + min-width: 0; + width: auto; + } + .page-wrap { padding: 16px 14px 24px; } diff --git a/app/static/js/play/app.js b/app/static/js/play/app.js index 61062fe..d58ff42 100644 --- a/app/static/js/play/app.js +++ b/app/static/js/play/app.js @@ -87,6 +87,7 @@ export class PlayApp { this.displayBoardSize = 640; this.squareSize = 80; this.dpr = Math.max(1, window.devicePixelRatio || 1); + this.urlJoinConsumed = false; this.loadPieceImages(); this.installControls(); @@ -127,6 +128,7 @@ export class PlayApp { installSocket() { this.socket = createWSClient({ + onServerReady: () => this.consumeJoinCodeFromUrl(), onGameStarted: handleGameStarted, onP2Connected: handleP2Connected, onGameCreated: handleCodeGameCreated, @@ -170,16 +172,7 @@ export class PlayApp { }); this.joinBtn.addEventListener("click", () => { - const code = this.joinInput.value.trim().toUpperCase(); - if (!code) { - this.showModal("Join game", "Enter a game code first.", [ - { label: "OK" }, - ]); - return; - } - this.gameCode = code; - this.socket.emit("join_code_game", { code }); - this.setLobbyStatus("Joining game..."); + this.joinCode(this.joinInput.value); }); this.copyCodeBtn.addEventListener("click", async () => { @@ -358,6 +351,40 @@ export class PlayApp { }); } + joinCode(rawCode) { + const code = String(rawCode || "").trim().toUpperCase(); + if (!code) { + this.showModal("Join game", "Enter a game code first.", [ + { label: "OK" }, + ]); + return false; + } + + this.gameCode = code; + this.joinInput.value = code; + this.socket.emit("join_code_game", { code }); + this.setLobbyStatus("Joining game..."); + return true; + } + + consumeJoinCodeFromUrl() { + if (this.urlJoinConsumed) { + return; + } + + const url = new URL(window.location.href); + const code = url.searchParams.get("code"); + if (!code) { + return; + } + + this.urlJoinConsumed = true; + url.searchParams.delete("code"); + const nextUrl = `${url.pathname}${url.search}${url.hash}`; + window.history.replaceState({}, "", nextUrl); + this.joinCode(code); + } + onCodeCreated(data) { this.gameCode = data.code; this.codeEl.textContent = data.code; diff --git a/app/templates/home.html b/app/templates/home.html index 06ad151..d58a7dd 100644 --- a/app/templates/home.html +++ b/app/templates/home.html @@ -10,4 +10,20 @@ >Past games +