diff --git a/backend/TASK.md b/backend/TASK.md index f0815e6..ebf6ee0 100644 --- a/backend/TASK.md +++ b/backend/TASK.md @@ -144,11 +144,11 @@ This document contains step-by-step tasks for implementing the backend API for C ### TASK-B403: Chess Real-time Features -- [ ] Setup chess room events -- [ ] Emit move updates -- [ ] Add turn timer -- [ ] Handle disconnection/reconnection -- [ ] Broadcast game results +- [x] Setup chess room events +- [x] Emit move updates +- [x] Add turn timer +- [x] Handle disconnection/reconnection +- [x] Broadcast game results ## Game: Pac-Man diff --git a/backend/migrations/007_chess_game.sql b/backend/migrations/007_chess_game.sql index dba1ea6..84c3925 100644 --- a/backend/migrations/007_chess_game.sql +++ b/backend/migrations/007_chess_game.sql @@ -1,8 +1,8 @@ -CREATE TABLE Games ( +CREATE TABLE games ( game_id SERIAL PRIMARY KEY, - white_player_id INTEGER NOT NULL REFERENCES Users(id), - black_player_id INTEGER NOT NULL REFERENCES Users(id), + white_player_id INTEGER NOT NULL, + black_player_id INTEGER NOT NULL, start_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, end_time TIMESTAMP, @@ -22,7 +22,8 @@ CREATE TABLE Games ( current_turn CHAR(1) NOT NULL CHECK (current_turn IN ('w','b')), - is_check BOOLEAN, + + is_check BOOLEAN DEFAULT false, draw_offer_by INTEGER, draw_status VARCHAR(20) DEFAULT 'none' ); \ No newline at end of file diff --git a/backend/migrations/008_chess_Move.sql b/backend/migrations/008_chess_Move.sql index bbbc194..6f404e7 100644 --- a/backend/migrations/008_chess_Move.sql +++ b/backend/migrations/008_chess_Move.sql @@ -1,32 +1,20 @@ CREATE TABLE Moves ( move_id SERIAL PRIMARY KEY, - game_id INTEGER NOT NULL, - move_number INTEGER NOT NULL, - player_color CHAR(1) NOT NULL CHECK (player_color IN ('w','b')), - from_square VARCHAR(2) NOT NULL, to_square VARCHAR(2) NOT NULL, - piece VARCHAR(2), - capture BOOLEAN DEFAULT FALSE, + capture VARCHAR(5), promotion VARCHAR(2), - notation VARCHAR(10) NOT NULL, - fen_after TEXT NOT NULL, - move_time INTEGER, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_game FOREIGN KEY (game_id) REFERENCES Games(game_id) - ON DELETE CASCADE, - - UNIQUE (game_id, move_number) + ON DELETE CASCADE ); \ No newline at end of file diff --git a/backend/src/config/handlers/chess-events.handler.ts b/backend/src/config/handlers/chess-events.handler.ts new file mode 100644 index 0000000..a9b4259 --- /dev/null +++ b/backend/src/config/handlers/chess-events.handler.ts @@ -0,0 +1,119 @@ + +import { Server, Socket } from "socket.io"; +import { RoomManager } from "@/utils/room-manager"; +import { ChessService } from "@/services/chess/chess.services"; +import { ChessModel } from "@/models/chess.model"; +import { ChessSocket } from "@/sockets/chess.socket"; +import { GameDisconnectHandler } from "@/services/chess/chess.disconnecthandler"; +import logger from "@/utils/logger"; + +const chessSocket = new ChessSocket(); +const chessService = new ChessService(chessSocket, new ChessModel()); + +const clockIntervals = new Map(); + +function startClockSync(io: Server, gameId: string, getGame: () => Promise) { + if (clockIntervals.has(gameId)) clearInterval(clockIntervals.get(gameId)!); + + const interval = setInterval(async () => { + try { + const game = await getGame(); + if (!game || game.status !== "ongoing") { + clearInterval(interval); + clockIntervals.delete(gameId); + return; + } + io.to(`game:${gameId}`).emit("chess:clockSync", { + w: Math.floor(game.white_time_left / 1000), + b: Math.floor(game.black_time_left / 1000), + }); + } catch { + clearInterval(interval); + clockIntervals.delete(gameId); + } + }, 1000); + + clockIntervals.set(gameId, interval); +} + + + +export function registerChessEvents( + io: Server, + socket: Socket, + roomManager: RoomManager, + userSocketMap: Map, +) { + socket.on("chess:startClock", (data: { gameId: string }) => { + const userId = socket.data.userId; + if (!userId) { socket.emit("chess:error", { message: "Not authenticated" }); return; } + startClockSync(io, data.gameId, () => chessService.fetchGame(data.gameId)); + logger.info(`Clock sync started for game ${data.gameId}`); + }); + + socket.on("chess:resign", async (data: { gameId: string }) => { + try { + const userId = socket.data.userId; + if (!userId) { socket.emit("chess:error", { message: "Not authenticated" }); return; } + await chessService.resignGame(data.gameId, userId); + if (clockIntervals.has(data.gameId)) { + clearInterval(clockIntervals.get(data.gameId)!); + clockIntervals.delete(data.gameId); + } + logger.info(`User ${userId} resigned game ${data.gameId}`); + } catch (err: any) { + socket.emit("chess:error", { message: err.message || "Failed to resign" }); + } + }); + + socket.on("chess:offerDraw", async (data: { gameId: string }) => { + try { + const userId = socket.data.userId; + if (!userId) { socket.emit("chess:error", { message: "Not authenticated" }); return; } + await chessService.offerDraw(data.gameId, userId); + } catch (err: any) { + socket.emit("chess:error", { message: err.message || "Failed to offer draw" }); + } + }); + + socket.on("chess:acceptDraw", async (data: { gameId: string }) => { + try { + const userId = socket.data.userId; + if (!userId) { socket.emit("chess:error", { message: "Not authenticated" }); return; } + await chessService.acceptDraw(data.gameId, userId); + if (clockIntervals.has(data.gameId)) { + clearInterval(clockIntervals.get(data.gameId)!); + clockIntervals.delete(data.gameId); + } + } catch (err: any) { + socket.emit("chess:error", { message: err.message || "Failed to accept draw" }); + } + }); + + socket.on("chess:declineDraw", async (data: { gameId: string }) => { + try { + const userId = socket.data.userId; + if (!userId) { socket.emit("chess:error", { message: "Not authenticated" }); return; } + await chessService.rejectDraw(data.gameId, userId); + } catch (err: any) { + socket.emit("chess:error", { message: err.message || "Failed to decline draw" }); + } + }); + + socket.on("disconnect", () => { + const userId = socket.data.userId; + if (!userId) return; + const playerRoom = roomManager.getPlayerRoom(socket.id); + if (!playerRoom || playerRoom.gameType !== "chess") return; + const gameId = playerRoom.gameId; + if (!gameId) return; + + const disconnectHandler = new GameDisconnectHandler(io, chessService, new ChessModel(), chessSocket); + disconnectHandler.handleDisconnect(gameId, userId); + + if (clockIntervals.has(gameId)) { + clearInterval(clockIntervals.get(gameId)!); + clockIntervals.delete(gameId); + } + }); +} \ No newline at end of file diff --git a/backend/src/config/handlers/game-events.handler.ts b/backend/src/config/handlers/game-events.handler.ts index 8fcee64..d23be3d 100644 --- a/backend/src/config/handlers/game-events.handler.ts +++ b/backend/src/config/handlers/game-events.handler.ts @@ -1,3 +1,4 @@ + import { Server, Socket } from "socket.io"; import { RoomManager } from "@/utils/room-manager"; import { TicTacToeService } from "@/services/tictactoe/tictactoe.service"; @@ -55,6 +56,8 @@ export function registerGameEvents( }); socket.on("game:move", async (data: { gameId: string; gameType?: string; moveData: any }) => { + console.log("backend game:move received from", socket.id, "gameId:", data.gameId); + try { const playerRoom = roomManager.getPlayerRoom(socket.id); if (!playerRoom) { @@ -133,7 +136,7 @@ export function registerGameEvents( roomManager.setGameState(playerRoom.id, game); // Broadcast move to all players in game room - io.to(`game:${data.gameId}`).emit("game:move", { + socket.to(`game:${data.gameId}`).emit("game:move", { gameId: data.gameId, gameType, moveData: data.moveData, @@ -144,6 +147,7 @@ export function registerGameEvents( logger.info(`User ${socket.id} made move in game ${data.gameId} (${gameType})`); } catch (err) { logger.error("Error making move", { error: err }); + console.error("Full move error:", err); socket.emit("game:error", { message: "Failed to make move" }); } }); diff --git a/backend/src/config/handlers/index.ts b/backend/src/config/handlers/index.ts index 30d2ece..1a7f6b8 100644 --- a/backend/src/config/handlers/index.ts +++ b/backend/src/config/handlers/index.ts @@ -2,3 +2,4 @@ export * from "./room-events.handler"; export * from "./game-events.handler"; export * from "./chat-events.handler"; export * from "./disconnect-events.handler"; +export * from "./chess-events.handler"; \ No newline at end of file diff --git a/backend/src/config/handlers/room-events.handler.ts b/backend/src/config/handlers/room-events.handler.ts index a9d5ba0..1867ac1 100644 --- a/backend/src/config/handlers/room-events.handler.ts +++ b/backend/src/config/handlers/room-events.handler.ts @@ -1,79 +1,39 @@ import { Server, Socket } from "socket.io"; import { RoomManager } from "@/utils/room-manager"; +import { ChessService } from "@/services/chess/chess.services"; +import { ChessModel } from "@/models/chess.model"; +import { ChessSocket } from "@/sockets/chess.socket"; +import { userSocketMap } from "@/config/socket-server"; import logger from "@/utils/logger"; -export function registerRoomEvents( - io: Server, - socket: Socket, - roomManager: RoomManager, -) { - socket.on( - "room:create", - (data: { - roomName?: string; - gameType?: "tictactoe" | "snake" | "rps" | "chess"; - }) => { - try { - const room = roomManager.createRoom( - socket.id, - data?.roomName, - data?.gameType, - ); - socket.join(room.id); - - socket.emit("room:created", { - success: true, - room: roomManager.getRoomInfo(room.id), - }); +const chessSocket = new ChessSocket(); +const chessService = new ChessService(chessSocket, new ChessModel()); - logger.info(`Created room ${room.id} for user ${socket.id}`); - // Broadcast only to clients interested in this game type - const filteredRooms = roomManager - .listRooms() - .filter((room) => room.gameType === data?.gameType); - io.emit("rooms:list", { - gameType: data?.gameType, - rooms: filteredRooms, - }); - } catch (err) { - logger.error("Error creating room", { error: err }); - socket.emit("room:error", { message: "Failed to create room" }); - } - }, - ); +export function registerRoomEvents(io: Server, socket: Socket, roomManager: RoomManager) { + socket.on("room:create", (data: { roomName?: string; gameType?: "tictactoe" | "snake" | "rps" | "chess" }) => { + try { + const room = roomManager.createRoom(socket.id, data?.roomName, data?.gameType); + socket.join(room.id); + socket.emit("room:created", { success: true, room: roomManager.getRoomInfo(room.id) }); + logger.info(`Created room ${room.id} for user ${socket.id}`); + const filteredRooms = roomManager.listRooms().filter((r) => r.gameType === data?.gameType); + io.emit("rooms:list", { gameType: data?.gameType, rooms: filteredRooms }); + } catch (err) { + logger.error("Error creating room", { error: err }); + socket.emit("room:error", { message: "Failed to create room" }); + } + }); socket.on("room:join", (data: { roomId: string }) => { try { const success = roomManager.joinRoom(data.roomId, socket.id); - console.log("joinRoom result:", data, success); - - if (!success) { - socket.emit("room:error", { message: "Room not found" }); - return; - } - + if (!success) { socket.emit("room:error", { message: "Room not found" }); return; } socket.join(data.roomId); - const room = roomManager.getRoom(data.roomId); - socket.emit("room:joined", { - success: true, - room: roomManager.getRoomInfo(data.roomId), - }); - - // Notify others in the room - socket.to(data.roomId).emit("player:joined", { - playerId: socket.id, - room: roomManager.getRoomInfo(data.roomId), - }); - - // Broadcast updated room list to only this game type - const filteredRooms = roomManager - .listRooms() - .filter((r) => r.gameType === room?.gameType); - io.emit("rooms:list", { - gameType: room?.gameType, - rooms: filteredRooms, - }); + socket.emit("room:joined", { success: true, room: roomManager.getRoomInfo(data.roomId) }); + socket.to(data.roomId).emit("player:joined", { playerId: socket.id, room: roomManager.getRoomInfo(data.roomId) }); + const filteredRooms = roomManager.listRooms().filter((r) => r.gameType === room?.gameType); + io.emit("rooms:list", { gameType: room?.gameType, rooms: filteredRooms }); } catch (err) { logger.error("Error joining room", { error: err }); socket.emit("room:error", { message: "Failed to join room" }); @@ -85,67 +45,101 @@ export function registerRoomEvents( const room = roomManager.getRoom(data.roomId); roomManager.leaveRoom(data.roomId, socket.id); socket.leave(data.roomId); - socket.emit("room:left", { success: true }); - - // Notify others in the room - socket.to(data.roomId).emit("player:left", { - playerId: socket.id, - room: roomManager.getRoomInfo(data.roomId), - }); - - // Broadcast updated room list to only this game type - const filteredRooms = roomManager - .listRooms() - .filter((r) => r.gameType === room?.gameType); - io.emit("rooms:list", { - gameType: room?.gameType, - rooms: filteredRooms, - }); + socket.to(data.roomId).emit("player:left", { playerId: socket.id, room: roomManager.getRoomInfo(data.roomId) }); + const filteredRooms = roomManager.listRooms().filter((r) => r.gameType === room?.gameType); + io.emit("rooms:list", { gameType: room?.gameType, rooms: filteredRooms }); } catch (err) { logger.error("Error leaving room", { error: err }); socket.emit("room:error", { message: "Failed to leave room" }); } }); - // host starts match within room - socket.on("room:start", (data: { roomId: string }) => { + // Host starts match within room + socket.on("room:start", async (data: { roomId: string }) => { try { const roomInfo = roomManager.getRoomInfo(data.roomId); - if (!roomInfo) { - socket.emit("room:error", { message: "Room not found" }); + if (!roomInfo) { socket.emit("room:error", { message: "Room not found" }); return; } + + // Non-chess games use match:started + if (roomInfo.gameType !== "chess") { + io.to(data.roomId).emit("match:started"); return; } - // broadcast to everyone in the room that game is starting - io.to(data.roomId).emit("match:started"); + const room = roomManager.getRoom(data.roomId); + if (!room || room.players.size < 2) { + socket.emit("room:error", { message: "Need 2 players to start" }); + return; + } + + const [hostSocketId, guestSocketId] = Array.from(room.players); + + // Resolve userIds from socket IDs + const hostSocket = io.sockets.sockets.get(hostSocketId); + const guestSocket = io.sockets.sockets.get(guestSocketId); + + const hostUserId = hostSocket?.data?.userId; + const guestUserId = guestSocket?.data?.userId; + + if (!hostUserId || !guestUserId) { + logger.warn("User IDs missing", { + hostSocketId, + guestSocketId, + hostUserId, + guestUserId, + }); + + socket.emit("room:error", { + message: "Players not registered properly", + }); + return; + } + + if (!hostUserId || !guestUserId) { + socket.emit("room:error", { message: "Could not resolve player IDs" }); + return; + } + + const timeControl = "10+0"; + + // Create chess game — host is white, guest is black + const game = await chessService.startGame( + { blackPlayerId: guestUserId, timeControl }, + hostUserId, + ); + + // Emit chess:gameStart to each player with their color + io.to(hostSocketId).emit("chess:gameStart", { + gameId: game.game_id, + playerColor: "w", + timeControl: game.white_time_left / 1000, + }); + + io.to(guestSocketId).emit("chess:gameStart", { + gameId: game.game_id, + playerColor: "b", + timeControl: game.black_time_left / 1000, + }); + + logger.info(`Chess game ${game.game_id} started in room ${data.roomId}`); } catch (err) { logger.error("Error starting match", { error: err }); socket.emit("room:error", { message: "Failed to start match" }); } }); - socket.on( - "rooms:get", - (data?: { gameType?: "tictactoe" | "snake" | "rps" | "chess" }) => { - const filteredRooms = roomManager - .listRooms() - .filter((room) => - data?.gameType ? room.gameType === data.gameType : true, - ); - socket.emit("rooms:list", { - gameType: data?.gameType, - rooms: filteredRooms, - }); - }, - ); + socket.on("rooms:get", (data?: { gameType?: "tictactoe" | "snake" | "rps" | "chess" }) => { + const filteredRooms = roomManager.listRooms().filter((room) => + data?.gameType ? room.gameType === data.gameType : true, + ); + socket.emit("rooms:list", { gameType: data?.gameType, rooms: filteredRooms }); + }); socket.on("room:get", (data: { roomId: string }) => { const roomInfo = roomManager.getRoomInfo(data.roomId); - if (roomInfo) { - socket.emit("room:info", roomInfo); - } else { - socket.emit("room:error", { message: "Room not found" }); - } + if (roomInfo) { socket.emit("room:info", roomInfo); } + else { socket.emit("room:error", { message: "Room not found" }); } }); } + diff --git a/backend/src/config/socket-server.ts b/backend/src/config/socket-server.ts index 3fe3956..737778d 100644 --- a/backend/src/config/socket-server.ts +++ b/backend/src/config/socket-server.ts @@ -1,12 +1,14 @@ -import { Server, Socket } from "socket.io"; +import { Server } from "socket.io"; import { Server as HTTPServer } from "http"; import { RoomManager } from "@/utils/room-manager"; import logger from "@/utils/logger"; + import { registerRoomEvents, registerGameEvents, registerChatEvents, registerDisconnectEvents, + registerChessEvents, } from "./handlers"; export const userSocketMap = new Map(); @@ -24,26 +26,30 @@ export function initializeSocket(server: HTTPServer) { ioServer = io; io.on("connection", (socket) => { + const userId = (socket.handshake.query.userId as string) || (socket.handshake.headers["user-id"] as string); - const gameType = - (socket.handshake.query.gameType as string) || - (socket.handshake.headers["game-type"] as string); if (userId) { userSocketMap.set(userId, socket.id); } - console.log("A user connected", socket.id, userSocketMap); + console.log("A user connected", socket.id); + + socket.on("user:register", ({ userId }) => { + userSocketMap.set(userId, socket.id); + socket.data.userId = Number(userId); + + console.log("User registered:", userId, "->", socket.id); + }); - // Send initial rooms list filtered by game type - const filteredRooms = roomManager - .listRooms() - .filter((room) => (gameType ? room.gameType === gameType : true)); - socket.emit("rooms:list", { gameType, rooms: filteredRooms }); + // initial rooms emit + socket.emit("rooms:list", { + rooms: roomManager.listRooms(), + }); - // Register all event handlers + // handlers registerRoomEvents(io, socket, roomManager); registerGameEvents(io, socket, roomManager, userSocketMap); registerChatEvents(io, socket); @@ -51,15 +57,13 @@ export function initializeSocket(server: HTTPServer) { }); io.on("connect_error", (err) => { - logger.error("Global socket connection error", { error: err }); + logger.error("Socket error", { error: err }); }); return io; } export const getIO = (): Server => { - if (!ioServer) { - throw new Error("Socket.io not initialized! Call initSocket first."); - } + if (!ioServer) throw new Error("Socket not initialized"); return ioServer; -}; +}; \ No newline at end of file diff --git a/backend/src/models/chess.model.ts b/backend/src/models/chess.model.ts index 2b62672..382df9c 100644 --- a/backend/src/models/chess.model.ts +++ b/backend/src/models/chess.model.ts @@ -25,33 +25,39 @@ export class ChessModel extends GameModel { // 1️⃣ Create Game async createGame(gameData: ChessData): Promise { - const query = ` - INSERT INTO Games ( - white_player_id, - black_player_id, - status, - time_control, - pgn_data, - fen_position, - current_turn - ) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *; - `; + const query = ` + INSERT INTO Games ( + white_player_id, + black_player_id, + status, + time_control, + pgn_data, + fen_position, + current_turn, + white_time_left, + black_time_left, + last_move_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *; + `; const values = [ - gameData.white_player_id, - gameData.black_player_id, - gameData.status || 'ongoing', - gameData.time_control, - gameData.pgn_data || "", - gameData.fen_position || new Chess().fen(), - gameData.current_turn || "w" - ]; + gameData.white_player_id, + gameData.black_player_id, + gameData.status || "ongoing", + gameData.time_control, + gameData.pgn_data || "", + gameData.fen_position || new Chess().fen(), + gameData.current_turn || "w", + gameData.white_time_left, + gameData.black_time_left, + gameData.last_move_at, + ]; const result = await pool.query(query, values); return result.rows[0]; - } +} // 2️⃣ Get Game + Moves async getGameData(gameId: string): Promise { @@ -76,31 +82,28 @@ export class ChessModel extends GameModel { // 3️⃣ Update Game State async updateGameState(gameId: string, gameData: any): Promise { - const query = ` - UPDATE Games - SET - pgn_data = $1, - fen_position = $2, - current_turn = $3, - status = $4, - winner = $5, - is_check = $6 - WHERE game_id = $7 - RETURNING *; - `; - - const values = [ - gameData.pgn, - gameData.fen_position, - gameData.current_turn, - gameData.status, - gameData.winner, - gameData.is_check, - gameId - ]; - - const result = await pool.query(query, values); - return result.rows[0]; + const fields: string[] = []; + const values: any[] = []; + let i = 1; + + if (gameData.pgn !== undefined) { fields.push(`pgn_data = $${i++}`); values.push(gameData.pgn); } + if (gameData.fen_position !== undefined) { fields.push(`fen_position = $${i++}`); values.push(gameData.fen_position); } + if (gameData.current_turn !== undefined) { fields.push(`current_turn = $${i++}`); values.push(gameData.current_turn); } + if (gameData.status !== undefined) { fields.push(`status = $${i++}`); values.push(gameData.status); } + if (gameData.winner !== undefined) { fields.push(`winner = $${i++}`); values.push(gameData.winner); } + if (gameData.is_check !== undefined) { fields.push(`is_check = $${i++}`); values.push(gameData.is_check); } + if (gameData.white_time_left !== undefined){ fields.push(`white_time_left = $${i++}`);values.push(gameData.white_time_left); } + if (gameData.black_time_left !== undefined){ fields.push(`black_time_left = $${i++}`);values.push(gameData.black_time_left); } + if (gameData.last_move_at !== undefined) { fields.push(`last_move_at = $${i++}`); values.push(gameData.last_move_at); } + if (gameData.draw_status !== undefined) { fields.push(`draw_status = $${i++}`); values.push(gameData.draw_status); } + if (gameData.draw_offer_by !== undefined) { fields.push(`draw_offer_by = $${i++}`); values.push(gameData.draw_offer_by); } + + if (fields.length === 0) throw new Error("No fields to update"); + + values.push(gameId); + const query = `UPDATE Games SET ${fields.join(", ")} WHERE game_id = $${i} RETURNING *`; + const result = await pool.query(query, values); + return result.rows[0]; } // 4️⃣ Reset Game @@ -187,7 +190,7 @@ async updateGameState(gameId: string, gameData: any): Promise { data.from, data.to, data.piece || null, - data.capture || false, + data.capture ? true : false, data.promotion || null, data.notation, data.fenAfter diff --git a/backend/src/services/chess/chess.services.ts b/backend/src/services/chess/chess.services.ts index 8ee2bd2..ac6f457 100644 --- a/backend/src/services/chess/chess.services.ts +++ b/backend/src/services/chess/chess.services.ts @@ -50,6 +50,8 @@ export class ChessService extends GameService { const now = Date.now(); const timeSpent = now - game.last_move_at; + console.log("time debug:", { now, last_move_at: game.last_move_at, timeSpent, white_time_left: game.white_time_left }); + // Deduct time if (game.current_turn === "w") { @@ -102,7 +104,10 @@ export class ChessService extends GameService { move: result.move }); - return updatedGame; + return { + ...updatedGame, + move: result.move, + }; } diff --git a/backend/src/services/chess/core/game.getstatus.ts b/backend/src/services/chess/core/game.getstatus.ts index 6d8c0b0..a0b22ad 100644 --- a/backend/src/services/chess/core/game.getstatus.ts +++ b/backend/src/services/chess/core/game.getstatus.ts @@ -1,38 +1,75 @@ -import { Chess } from 'chess.js'; -import { GameStatusResult, GameStatus } from '../types/chess.types'; +import { Chess } from "chess.js"; +import { GameStatusResult, GameStatus } from "../types/chess.types"; export default function getGameStatus( - engine: Chess, - isTimeout: boolean = false, - timedOutPlayer: 'w' | 'b' | null = null + engine: Chess, + isTimeout: boolean = false, + timedOutPlayer: "w" | "b" | null = null ): GameStatusResult { - + + // ❗ SAFETY: engine must always be valid + if (!engine) { + return { + winner: null, + reason: "ongoing", + isDraw: false, + }; + } + + // ------------------------- + // ⏱ TIMEOUT HANDLING + // ------------------------- if (isTimeout && timedOutPlayer) { - const opponent = timedOutPlayer === 'w' ? 'black' : 'white'; - const canOpponentWin = !engine.isInsufficientMaterial(); + const winner: "white" | "black" = + timedOutPlayer === "w" ? "black" : "white"; + + const insufficientMaterial = engine.isInsufficientMaterial(); + return { - winner: canOpponentWin ? opponent : null, - reason: canOpponentWin ? 'timeout' : 'timeout_with_insufficient_material', - isDraw: !canOpponentWin + winner: insufficientMaterial ? null : winner, + reason: insufficientMaterial + ? "timeout_with_insufficient_material" + : "timeout", + isDraw: insufficientMaterial, }; } + // ------------------------- + // 🚨 CHECKMATE + // ------------------------- if (engine.isCheckmate()) { - return { - winner: engine.turn() === 'w' ? 'black' : 'white', - reason: 'checkmate', - isDraw: false + return { + winner: engine.turn() === "w" ? "black" : "white", + reason: "checkmate", + isDraw: false, }; - } +} + // ------------------------- + // 🤝 DRAW CONDITIONS + // ------------------------- if (engine.isDraw()) { - let reason: GameStatus = 'draw'; - if (engine.isStalemate()) reason = 'stalemate'; - else if (engine.isThreefoldRepetition()) reason = 'repetition'; - else if (engine.isInsufficientMaterial()) reason = 'insufficient_material'; - else reason = '50_move_rule'; - return { winner: null, reason, isDraw: true }; + let reason: GameStatus = "draw"; + + if (engine.isStalemate()) reason = "stalemate"; + else if (engine.isThreefoldRepetition()) reason = "repetition"; + else if (engine.isInsufficientMaterial()) + reason = "insufficient_material"; + else reason = "50_move_rule"; + + return { + winner: null, + reason, + isDraw: true, + }; } - return { winner: null, reason: 'ongoing', isDraw: false }; + // ------------------------- + // ♟ NORMAL GAME STATE + // ------------------------- + return { + winner: null, + reason: "ongoing", + isDraw: false, + }; } \ No newline at end of file diff --git a/backend/src/services/chess/core/game.makeMove.ts b/backend/src/services/chess/core/game.makeMove.ts index ed0421a..da70436 100644 --- a/backend/src/services/chess/core/game.makeMove.ts +++ b/backend/src/services/chess/core/game.makeMove.ts @@ -5,41 +5,45 @@ import getGameStatus from "./game.getstatus"; export class ChessMovement { private model = new ChessModel(); - async execute(gameId: string, playerId: number, from: string, to: string, promotion?: string) { + async execute( + gameId: string, + playerId: number, + from: string, + to: string, + promotion?: string + ) { const game = await this.model.getGameData(gameId); + if (!game) throw new Error("Game not found!"); if (game.status !== "ongoing") throw new Error("Game already finished"); - // 1. Turn Validation - const expectedPlayerId = game.current_turn === 'w' ? game.white_player_id : game.black_player_id; - if (playerId !== expectedPlayerId) throw new Error("Not your turn!"); + // 1. Turn validation + const expectedPlayerId = + game.current_turn === "w" + ? game.white_player_id + : game.black_player_id; - const engine = new Chess(); - - try { - - if (game.pgn_data && game.pgn_data.trim() !== "") { - engine.loadPgn(game.pgn_data); - } else { - engine.load(game.fen_position); - } - } catch (e) { - - engine.load(game.fen_position); + if (playerId !== expectedPlayerId) { + throw new Error("Not your turn!"); } - + const engine = new Chess(game.fen_position); - // 2. Move Execution - const moveResult = engine.move({ - from, - to, - promotion: promotion || 'q' + // 2. Move execution + const moveResult = engine.move({ + from, + to, + promotion: promotion || "q", }); - if (!moveResult) throw new Error("Invalid move"); + if (!moveResult) { + throw new Error("Invalid move"); + } + + // 3. Compute status (NOW RELIABLE) + const statusResult = getGameStatus(engine); - // 3. Record Move History (Async pero hihintayin natin) + // 4. Save move history await this.model.recordMove({ gameId, moveNumber: engine.history().length, @@ -48,31 +52,34 @@ export class ChessMovement { to: moveResult.to, fenAfter: engine.fen(), piece: moveResult.piece, - capture: (moveResult.captured as string) || null, - promotion: (moveResult.promotion as string) || null, - color: moveResult.color + capture: moveResult.captured ? true : false, + promotion: moveResult.promotion || null, + color: moveResult.color, }); - // 4. Update Main Game State - const statusResult = getGameStatus(engine); - const updateData = { + // 5. Update game state + const dbStatus = ["repetition", "insufficient_material", "50_move_rule"].includes(statusResult.reason) + ? "draw" + : statusResult.reason; + + const updateData = { fen_position: engine.fen(), pgn_data: engine.pgn(), current_turn: engine.turn(), - is_check: engine.inCheck(), + is_check: engine.isCheck(), winner: statusResult.winner, - status: statusResult.reason + status: statusResult.isDraw ? "draw" : statusResult.reason }; await this.model.updateGameState(gameId, updateData); - + return { gameId, fen: engine.fen(), pgn: engine.pgn(), turn: engine.turn(), status: statusResult, - move: moveResult + move: moveResult, }; } } \ No newline at end of file diff --git a/frontend/TASK.md b/frontend/TASK.md index 628ca6a..789c081 100644 --- a/frontend/TASK.md +++ b/frontend/TASK.md @@ -174,13 +174,13 @@ This document contains step-by-step tasks for implementing the frontend UI for C ### TASK-F501: Chess UI Components -- [ ] Install react-chess.js or create custom board -- [ ] Create chessboard component -- [ ] Design chess pieces -- [ ] Add piece drag-and-drop +- [x] Install react-chess.js or create custom board +- [x] Create chessboard component +- [x] Design chess pieces +- [x] Add piece drag-and-drop - [x] Highlight legal moves - [x] Add move notation display -- [ ] Create captured pieces display +- [x] Create captured pieces display ### TASK-F502: Chess Game Page @@ -194,12 +194,12 @@ This document contains step-by-step tasks for implementing the frontend UI for C ### TASK-F503: Chess Multiplayer -- [ ] Create game lobby -- [ ] Add player matching -- [ ] Handle real-time moves +- [x] Create game lobby +- [x] Add player matching +- [x] Handle real-time moves - [ ] Add spectator mode -- [ ] Show opponent's time -- [ ] Implement resign/draw buttons +- [x] Show opponent's time +- [x] Implement resign/draw buttons ## Game: Pac-Man diff --git a/frontend/src/components/chess/ChessBoard.tsx b/frontend/src/components/chess/ChessBoard.tsx index 9301e4d..adf69c7 100644 --- a/frontend/src/components/chess/ChessBoard.tsx +++ b/frontend/src/components/chess/ChessBoard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { Chess } from "chess.js"; import type { ValidationResult, GameStatus } from "@/types/chess.type"; import { cn } from "@/lib/utils"; @@ -60,6 +60,9 @@ export default function ChessBoard({ } | null>(null); const [isFlashing, setIsFlashing] = useState(false); + // Prevents click handler from firing after a drag-and-drop move + const isDragging = useRef(false); + const pieceMap = parseFen(position); const files = orientation === "w" ? FILES : [...FILES].reverse(); const ranks = orientation === "w" ? RANKS : [...RANKS].reverse(); @@ -99,11 +102,10 @@ export default function ChessBoard({ return; } - // Save the starting square in the drag event + isDragging.current = true; e.dataTransfer.setData("text/plain", square); e.dataTransfer.effectAllowed = "move"; - // Instantly select the piece and show legal moves while dragging! if (pieceMap[square]) { setSelected(square); setLegalSquares(getLegalSquares(square, position)); @@ -113,7 +115,6 @@ export default function ChessBoard({ ); const handleDragOver = useCallback((e: React.DragEvent) => { - // We MUST prevent default here to tell the browser "yes, you can drop here" e.preventDefault(); e.dataTransfer.dropEffect = "move"; }, []); @@ -123,14 +124,13 @@ export default function ChessBoard({ e.preventDefault(); const sourceSquare = e.dataTransfer.getData("text/plain") as Square; - // If they dropped it somewhere weird or on the exact same square, cancel if (!sourceSquare || sourceSquare === targetSquare) { setSelected(null); setLegalSquares(new Set()); + isDragging.current = false; return; } - // If they dropped it on a legal square, execute the move! if (legalSquares.has(targetSquare)) { const piece = pieceMap[sourceSquare]; const isPromotion = @@ -141,22 +141,31 @@ export default function ChessBoard({ setPromotionPending({ from: sourceSquare, to: targetSquare }); setSelected(null); setLegalSquares(new Set()); + isDragging.current = false; return; } onMove(sourceSquare, targetSquare); } - // Reset the board selection after the drop setSelected(null); setLegalSquares(new Set()); + // Reset after a short delay so the click event can check it first + setTimeout(() => { isDragging.current = false; }, 50); }, [legalSquares, pieceMap, onMove], ); - // --- CLICK HANDLER (Kept as a fallback for accessibility!) --- + // --- CLICK HANDLER --- + const handleSquareClick = useCallback( (square: Square) => { + // Ignore click if it was triggered by a drag-and-drop + if (isDragging.current) { + isDragging.current = false; + return; + } + if (disabled || promotionPending) return; if (selected && legalSquares.has(square)) { @@ -249,7 +258,6 @@ export default function ChessBoard({ isCapture={legalSquares.has(square) && !!piece} disabled={disabled} onClick={() => handleSquareClick(square)} - // Wire up our new Drag functions! onDragStart={(e) => handleDragStart(e, square)} onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, square)} @@ -269,4 +277,4 @@ export default function ChessBoard({ )} ); -} +} \ No newline at end of file diff --git a/frontend/src/components/chess/GameAlerts.tsx b/frontend/src/components/chess/GameAlerts.tsx index 287e82b..aac6882 100644 --- a/frontend/src/components/chess/GameAlerts.tsx +++ b/frontend/src/components/chess/GameAlerts.tsx @@ -1,13 +1,14 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import type { GameStatus, Color } from "@/store/chess/useChessStore"; +import type { GameStatus, Color } from "@/types/chess.type"; // Types interface Props { status: GameStatus; - activeColor: Color; + activeColor: Color; // player viewing the board + winner?: Color | null; // NEW: actual winner from backend } interface AlertConfig { @@ -16,61 +17,80 @@ interface AlertConfig { description: string; } -// Alert Map +// Core logic -function resolveAlert(status: GameStatus, activeColor: Color): AlertConfig { - const side = activeColor === "w" ? "White" : "Black"; +function resolveAlert( + status: GameStatus, + activeColor: Color, + winner?: Color | null +): AlertConfig { + const isPlayerWinner = winner ? winner === activeColor : null; switch (status) { case "check": return { variant: "destructive", title: "⚠️ Check", - description: `${side} is in check!`, + description: "Your king is in check!", }; + case "checkmate": return { variant: "destructive", title: "Checkmate", - description: `${side} has been checkmated. Game over.`, + description: + winner === null + ? "Game over by checkmate." + : isPlayerWinner + ? "You won by checkmate!" + : "You have been checkmated.", }; + case "stalemate": return { variant: "default", title: "Stalemate", - description: "No legal moves — the game is a draw.", + description: "No legal moves available — draw.", }; + case "draw": return { variant: "default", title: "Draw", - description: "The game has ended in a draw.", + description: "The game ended in a draw.", }; + case "resigned": return { variant: "default", - title: "Resigned", - description: `${side} has resigned. Game over.`, + title: "Resignation", + description: isPlayerWinner + ? "Opponent resigned. You win!" + : "You resigned. Game over.", }; + case "playing": default: return { variant: "default", title: "Game in Progress", - description: `${side} to move.`, + description: "Your turn to play.", }; } } // Component -export default function GameAlerts({ status, activeColor }: Props) { - const config = resolveAlert(status, activeColor); +export default function GameAlerts({ + status, + activeColor, + winner = null, +}: Props) { + const config = resolveAlert(status, activeColor, winner); return ( @@ -81,4 +101,4 @@ export default function GameAlerts({ status, activeColor }: Props) { ); -} +} \ No newline at end of file diff --git a/frontend/src/components/chess/MoveHistory.tsx b/frontend/src/components/chess/MoveHistory.tsx index 4888a08..bba7147 100644 --- a/frontend/src/components/chess/MoveHistory.tsx +++ b/frontend/src/components/chess/MoveHistory.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from "react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { MoveEntry } from "@/store/chess/useChessStore"; +import type { MoveEntry } from "@/types/chess.type"; // Types diff --git a/frontend/src/components/chess/MoveValidationToast.tsx b/frontend/src/components/chess/MoveValidationToast.tsx index bf818fa..2a887b3 100644 --- a/frontend/src/components/chess/MoveValidationToast.tsx +++ b/frontend/src/components/chess/MoveValidationToast.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import { toast } from "sonner"; -import type { ValidationResult } from "@/store/chess/useChessStore"; +import type { ValidationResult } from "@/types/chess.type"; interface Props { result: ValidationResult | null; diff --git a/frontend/src/components/chess/board/TurnIndicator.tsx b/frontend/src/components/chess/board/TurnIndicator.tsx index 12f3cfd..5c28997 100644 --- a/frontend/src/components/chess/board/TurnIndicator.tsx +++ b/frontend/src/components/chess/board/TurnIndicator.tsx @@ -1,9 +1,7 @@ -// Indicator showing whose turn it is - "use client"; import { cn } from "@/lib/utils"; -import type { Color, GameStatus } from "@/store/chess/useChessStore"; +import type { Color, GameStatus } from "@/types/chess.type"; interface Props { activeColor: Color; @@ -11,22 +9,31 @@ interface Props { } export default function TurnIndicator({ activeColor, status }: Props) { - const isGameActive = status === "playing" || status === "check"; + const isActive = + status !== "checkmate" && + status !== "stalemate" && + status !== "draw" && + status !== "resigned"; - if (!isGameActive) return