From 1bd89c788eaa5a8c2a29016a05d00f20ef9d2325 Mon Sep 17 00:00:00 2001 From: gaganalexa <202310537@fit.edu.ph> Date: Wed, 6 May 2026 13:19:36 +0800 Subject: [PATCH 1/5] feat: integrate chess game functionality with socket.io - Added chess event handlers for game actions (start, resign, draw) in backend. - Implemented socket connection management for chess screens in frontend. - Enhanced game state management with draw offer handling and clock synchronization. - Updated Zustand store to include socket management and game state. - Refactored game and room screens to utilize new socket functionality. - Added chess.js library for chess logic handling. --- .../config/handlers/chess-events.handler.ts | 123 +++++++++++ backend/src/config/handlers/index.ts | 1 + .../config/handlers/room-events.handler.ts | 196 ++++++++---------- backend/src/config/socket-server.ts | 36 ++-- .../components/chess/screens/GameScreen.tsx | 121 ++++++----- .../components/chess/screens/IdleScreen.tsx | 36 ++-- .../components/chess/screens/LobbyScreen.tsx | 92 ++++---- .../components/chess/screens/RoomScreen.tsx | 42 ++-- frontend/src/hooks/chess/useChessSocket.ts | 19 ++ frontend/src/store/chess/gameSlice.ts | 183 ++++++++-------- frontend/src/store/chess/roomSlice.ts | 27 ++- frontend/src/store/chess/socketSlice.ts | 160 ++++++++++++++ frontend/src/store/chess/store.types.ts | 71 ++++--- frontend/src/store/chess/useChessStore.ts | 8 +- package.json | 1 + 15 files changed, 716 insertions(+), 400 deletions(-) create mode 100644 backend/src/config/handlers/chess-events.handler.ts create mode 100644 frontend/src/hooks/chess/useChessSocket.ts create mode 100644 frontend/src/store/chess/socketSlice.ts 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..23a7be1 --- /dev/null +++ b/backend/src/config/handlers/chess-events.handler.ts @@ -0,0 +1,123 @@ +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: game.white_time_left, + b: game.black_time_left, + }); + } catch { + clearInterval(interval); + clockIntervals.delete(gameId); + } + }, 1000); + + clockIntervals.set(gameId, interval); +} + +function getUserId(socket: Socket, userSocketMap: Map): number | null { + for (const [uid, sid] of userSocketMap.entries()) { + if (sid === socket.id) return parseInt(uid, 10); + } + return null; +} + +export function registerChessEvents( + io: Server, + socket: Socket, + roomManager: RoomManager, + userSocketMap: Map, +) { + socket.on("chess:startClock", (data: { gameId: string }) => { + const userId = getUserId(socket, userSocketMap); + 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 = getUserId(socket, userSocketMap); + 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 = getUserId(socket, userSocketMap); + 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 = getUserId(socket, userSocketMap); + 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 = getUserId(socket, userSocketMap); + 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 = getUserId(socket, userSocketMap); + 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/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..32bb840 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), - }); - - 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" }); - } - }, - ); +const chessSocket = new ChessSocket(); +const chessService = new ChessService(chessSocket, new ChessModel()); + +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,87 @@ 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; + } + + 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 + let hostUserId: number | null = null; + let guestUserId: number | null = null; + for (const [uid, sid] of userSocketMap.entries()) { + if (sid === hostSocketId) hostUserId = parseInt(uid, 10); + if (sid === guestSocketId) guestUserId = parseInt(uid, 10); + } + + if (!hostUserId || !guestUserId) { + socket.emit("room:error", { message: "Could not resolve player IDs" }); return; } - // broadcast to everyone in the room that game is starting - io.to(data.roomId).emit("match:started"); + 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" }); } }); -} +} \ No newline at end of file diff --git a/backend/src/config/socket-server.ts b/backend/src/config/socket-server.ts index 3fe3956..2fea341 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(); @@ -27,39 +29,41 @@ export function initializeSocket(server: HTTPServer) { 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); registerDisconnectEvents(io, socket, roomManager); + registerChessEvents(io, socket, roomManager, userSocketMap); }); 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/frontend/src/components/chess/screens/GameScreen.tsx b/frontend/src/components/chess/screens/GameScreen.tsx index fd75468..ff7d84a 100644 --- a/frontend/src/components/chess/screens/GameScreen.tsx +++ b/frontend/src/components/chess/screens/GameScreen.tsx @@ -8,16 +8,15 @@ import GameClock from "@/components/chess/GameClock"; import GameAlerts from "@/components/chess/GameAlerts"; import DrawOfferModal from "@/components/chess/DrawOfferModal"; import { Button } from "@/components/ui/button"; - -// FOR Captured Pieces +import { useChessSocket } from "@/hooks/chess/useChessSocket"; import { getMaterialAdvantage } from "@/lib/chess/chess-utils"; import CapturedPieces from "@/components/chess/CapturedPieces"; - -// UI Components import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; export default function GameScreen() { + useChessSocket(); + const { position, activeColor, @@ -30,12 +29,14 @@ export default function GameScreen() { tickClock, endGame, reset, + drawOfferedBy, + socket, + gameId, } = useChessStore(); - // Add this line right here! const material = getMaterialAdvantage(position); + const [localDrawModal, setLocalDrawModal] = useState(false); - const [showDrawModal, setShowDrawModal] = useState(false); const orientation = playerColor ?? "w"; const opponentColor = orientation === "w" ? "b" : "w"; @@ -44,19 +45,46 @@ export default function GameScreen() { status === "stalemate" || status === "draw" || status === "resigned"; - const isBoardDisabled = isGameOver; - // TODO: uncomment when player constraints are needed - // const isBoardDisabled = playerColor !== null - // ? activeColor !== orientation || status !== "playing" - // : status !== "playing"; + const isBoardDisabled = playerColor !== null + ? activeColor !== orientation || isGameOver + : isGameOver; + + const showDrawModal = localDrawModal || drawOfferedBy !== null; + const isRecipient = drawOfferedBy !== null && !localDrawModal; + + const handleOfferDraw = () => { + setLocalDrawModal(true); + if (socket && gameId) socket.emit("chess:offerDraw", { gameId }); + }; + + const handleAcceptDraw = () => { + if (socket && gameId) { + socket.emit("chess:acceptDraw", { gameId }); + } else { + endGame("draw"); + } + setLocalDrawModal(false); + }; + + const handleDeclineDraw = () => { + if (socket && gameId) socket.emit("chess:declineDraw", { gameId }); + setLocalDrawModal(false); + }; + + const handleResign = () => { + if (socket && gameId) { + socket.emit("chess:resign", { gameId }); + } else { + endGame("resigned"); + } + }; return ( - // Main Container: Center everything, use a column on mobile, row on large screens
- {/* LEFT COLUMN: Players & Board */} + {/* LEFT COLUMN */}
- {/* OPPONENT TOP BAR */} + {/* OPPONENT BAR */}
@@ -67,20 +95,12 @@ export default function GameScreen() {
-

- Opponent -

- +

Opponent

+ 1200 ELO
- {/* Captured pieces placeholder */} -
- {/* We will inject captured pieces here next */}
- {/* Clock inside the bar */} + {/* Clocks sync from socket.on("chess:clockSync") via socketSlice */}
0 - } + isActive={activeColor === opponentColor && !isGameOver && moveHistory.length > 0} onTick={() => tickClock(opponentColor)} onExpire={() => endGame("checkmate")} />
- {/* THE BOARD */} + {/* BOARD */}
- {/* PLAYER BOTTOM BAR */} + {/* PLAYER BAR */}
@@ -129,19 +144,12 @@ export default function GameScreen() {
-

- You -

- +

You

+ 1200 ELO
- {/* Captured pieces placeholder */}
- {/* We will inject captured pieces here next */}
- {/* Clock inside the bar */}
0 - } + isActive={activeColor === orientation && !isGameOver && moveHistory.length > 0} onTick={() => tickClock(orientation)} onExpire={() => endGame("resigned")} /> @@ -167,32 +169,29 @@ export default function GameScreen() {
- {/* RIGHT COLUMN: Sidebar (Move History & Actions) */} + {/* RIGHT COLUMN */}
- {/* 👇 ADD GAME ALERTS HERE 👇 */}
- {/* Fixed height container for move history so it doesn't stretch weirdly */}
- - {/* Game Controls */}
{!isGameOver ? ( <> @@ -211,13 +210,11 @@ export default function GameScreen() { {showDrawModal && ( { - endGame("draw"); - setShowDrawModal(false); - }} - onDecline={() => setShowDrawModal(false)} + isRecipient={isRecipient} + onAccept={handleAcceptDraw} + onDecline={handleDeclineDraw} /> )}
); -} +} \ No newline at end of file diff --git a/frontend/src/components/chess/screens/IdleScreen.tsx b/frontend/src/components/chess/screens/IdleScreen.tsx index 3c95df9..38af9b9 100644 --- a/frontend/src/components/chess/screens/IdleScreen.tsx +++ b/frontend/src/components/chess/screens/IdleScreen.tsx @@ -1,31 +1,43 @@ "use client"; import { useChessStore } from "@/store/chess/useChessStore"; +import { useChessSocket } from "@/hooks/chess/useChessSocket"; import { Button } from "@/components/ui/button"; export default function IdleScreen() { - const setPhase = useChessStore((s) => s.setPhase); + useChessSocket(); + + const { setPhase, socket } = useChessStore(); + + const handleFindGame = () => { + if (socket) { + socket.emit("rooms:get", { gameType: "chess" }); + } + setPhase("lobby"); + }; + + const handleCreateRoom = () => { + if (socket) { + socket.emit("room:create", { roomName: "Chess Room", gameType: "chess" }); + // Phase transitions to "room" via socket.on("room:created") in socketSlice + } else { + setPhase("lobby"); + } + }; return (
- +

Chess

Play chess with your Codev friends.

- {/* TODO: [F503 - Create game lobby] emit("chess:getRooms") via socket before navigating */} - - - {/* TODO: [F503 - Create game lobby] emit("chess:createRoom", { timeControl: 600 }) via socket */} - + +
); -} +} \ No newline at end of file diff --git a/frontend/src/components/chess/screens/LobbyScreen.tsx b/frontend/src/components/chess/screens/LobbyScreen.tsx index b4f9d95..b0be7f0 100644 --- a/frontend/src/components/chess/screens/LobbyScreen.tsx +++ b/frontend/src/components/chess/screens/LobbyScreen.tsx @@ -1,33 +1,30 @@ "use client"; +import { useEffect } from "react"; import { useChessStore } from "@/store/chess/useChessStore"; +import { useChessSocket } from "@/hooks/chess/useChessSocket"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { Room } from "@/types/chess.type"; export default function LobbyScreen() { - const { rooms, joinRoom, setPhase } = useChessStore(); + useChessSocket(); - // TODO: on mount emit("chess:getRooms") and listen for chess:rooms event → setRooms() + const { rooms, setPhase, socket } = useChessStore(); - const displayRooms = - rooms.length > 0 - ? rooms - : [ - { - id: "1", - name: "Room #1", - players: 1, - maxPlayers: 2 as const, - timeControl: 600, - }, - { - id: "2", - name: "Room #2", - players: 0, - maxPlayers: 2 as const, - timeControl: 300, - }, - ]; + // Fetch rooms on mount + useEffect(() => { + if (socket) { + socket.emit("rooms:get", { gameType: "chess" }); + } + }, [socket]); + + const handleJoin = (room: Room) => { + if (socket) { + socket.emit("room:join", { roomId: room.id }); + // Phase transitions to "room" via socket.on("room:joined") in socketSlice + } + }; return (
@@ -37,24 +34,39 @@ export default function LobbyScreen() { ← Back
-
- {displayRooms.map((room) => ( - - - {room.name} - - - - {room.players}/{room.maxPlayers} players ·{" "} - {room.timeControl / 60} min - - - - - ))} -
+ + {rooms.length === 0 ? ( +
+

+ No rooms available. Create one from the home screen! +

+ +
+ ) : ( +
+ {rooms.map((room) => ( + + + {room.name} + + + + {room.players}/{room.maxPlayers} players · {room.timeControl / 60} min + + + + + ))} +
+ )}
); -} +} \ No newline at end of file diff --git a/frontend/src/components/chess/screens/RoomScreen.tsx b/frontend/src/components/chess/screens/RoomScreen.tsx index 3592b35..c16363e 100644 --- a/frontend/src/components/chess/screens/RoomScreen.tsx +++ b/frontend/src/components/chess/screens/RoomScreen.tsx @@ -1,13 +1,29 @@ "use client"; import { useChessStore } from "@/store/chess/useChessStore"; +import { useChessSocket } from "@/hooks/chess/useChessSocket"; import { Button } from "@/components/ui/button"; export default function RoomScreen() { - const { currentRoom, leaveRoom, startGame } = useChessStore(); + useChessSocket(); + + const { currentRoom, leaveRoom, socket } = useChessStore(); + + // Game starts via socket.on("chess:gameStart") in socketSlice + + const handleLeave = () => { + if (socket && currentRoom) { + socket.emit("room:leave", { roomId: currentRoom.id }); + } + leaveRoom(); + }; + + const handleStart = () => { + if (socket && currentRoom) { + socket.emit("room:start", { roomId: currentRoom.id }); + } + }; - // TODO: listen for socket event "chess:gameStart" → startGame(color, timeControl) - // Remove [Dev] Start as White button when socket is wired return (
@@ -15,22 +31,20 @@ export default function RoomScreen() { {currentRoom?.name ?? "Room"}

- Waiting for an opponent… + {currentRoom?.players === 2 ? "Both players ready!" : "Waiting for an opponent…"} +

+

+ {currentRoom?.players ?? 1}/2 players · {(currentRoom?.timeControl ?? 600) / 60} min

- {/* Dev shortcut — remove before merging */} - + {currentRoom?.players === 2 && ( + + )} -
); -} +} \ No newline at end of file diff --git a/frontend/src/hooks/chess/useChessSocket.ts b/frontend/src/hooks/chess/useChessSocket.ts new file mode 100644 index 0000000..2b38a68 --- /dev/null +++ b/frontend/src/hooks/chess/useChessSocket.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; +import { useSocketContext } from "@/context/SocketContext"; +import { useChessStore } from "@/store/chess/useChessStore"; + +// Call this once at the top of each chess screen +// Wires the shared socket from SocketContext into the chess Zustand store +export function useChessSocket() { + const { socket } = useSocketContext(); + const setSocket = useChessStore((s) => s.setSocket); + const storeSocket = useChessStore((s) => s.socket); + + useEffect(() => { + if (!socket) return; + if (storeSocket === socket) return; // already set, don't re-register listeners + setSocket(socket); + }, [socket, setSocket, storeSocket]); +} \ No newline at end of file diff --git a/frontend/src/store/chess/gameSlice.ts b/frontend/src/store/chess/gameSlice.ts index b2041e8..dd232f4 100644 --- a/frontend/src/store/chess/gameSlice.ts +++ b/frontend/src/store/chess/gameSlice.ts @@ -4,108 +4,99 @@ import type { ChessStore, GameSlice } from "./store.types"; import type { Color } from "@/types/chess.type"; import { deriveStatus, INITIAL_FEN } from "@/lib/chess/chess-utils"; -// Engine sits outside the state, just like before const chessEngine = new Chess(); -export const createGameSlice: StateCreator = ( - set, - get, -) => ({ - position: INITIAL_FEN, - activeColor: "w" as Color, - status: "playing", - moveHistory: [], - lastValidation: null, - playerColor: null, - clocks: { w: 600, b: 600 }, +export const createGameSlice: StateCreator = (set, get) => ({ + position: INITIAL_FEN, + activeColor: "w" as Color, + status: "playing", + moveHistory: [], + lastValidation: null, + playerColor: null, + clocks: { w: 600, b: 600 }, + drawOfferedBy: null, - startGame: (playerColor, timeControl) => { - chessEngine.reset(); - set({ - phase: "game", // This touches RoomSlice state, which is totally allowed! - playerColor, - position: chessEngine.fen(), - activeColor: "w", - status: "playing", - moveHistory: [], - lastValidation: null, - clocks: { w: timeControl, b: timeControl }, - }); - }, + startGame: (playerColor, timeControl) => { + chessEngine.reset(); + set({ + phase: "game", + playerColor, + position: chessEngine.fen(), + activeColor: "w", + status: "playing", + moveHistory: [], + lastValidation: null, + clocks: { w: timeControl, b: timeControl }, + drawOfferedBy: null, + }); + }, - makeMove: (from, to, promotion) => { - const state = get(); - if (state.status !== "playing" && state.status !== "check") return; + makeMove: (from, to, promotion) => { + const state = get(); + if (state.status !== "playing" && state.status !== "check") return; + try { + const move = chessEngine.move({ from, to, promotion }); + if (!move) { + set({ lastValidation: { valid: false, reason: "Illegal move" } }); + return; + } + const status = deriveStatus(chessEngine); + const moveNumber = Math.floor(state.moveHistory.length / 2) + 1; + set({ + position: chessEngine.fen(), + activeColor: chessEngine.turn() as Color, + status, + lastValidation: { valid: true }, + moveHistory: [ + ...state.moveHistory, + { san: move.san, color: move.color as Color, moveNumber }, + ], + }); + } catch { + set({ lastValidation: { valid: false, reason: "Illegal move" } }); + } + }, - try { - const move = chessEngine.move({ from, to, promotion }); + setValidation: (result) => set({ lastValidation: result }), - if (!move) { - set({ lastValidation: { valid: false, reason: "Illegal move" } }); - return; - } - const status = deriveStatus(chessEngine); - const moveNumber = Math.floor(state.moveHistory.length / 2) + 1; + applyServerMove: (fen, san, color, status) => { + chessEngine.load(fen); + set((state) => ({ + position: fen, + status, + activeColor: chessEngine.turn() as Color, + moveHistory: [ + ...state.moveHistory, + { san, color, moveNumber: Math.floor(state.moveHistory.length / 2) + 1 }, + ], + })); + }, - set({ - position: chessEngine.fen(), - activeColor: chessEngine.turn() as Color, - status, - lastValidation: { valid: true }, - moveHistory: [ - ...state.moveHistory, - { san: move.san, color: move.color as Color, moveNumber }, - ], - }); - } catch { - set({ lastValidation: { valid: false, reason: "Illegal move" } }); - } - }, + tickClock: (color) => + set((state) => ({ + clocks: { + ...state.clocks, + [color]: Math.max(0, state.clocks[color] - 1), + }, + })), - setValidation: (result) => set({ lastValidation: result }), + endGame: (status) => set({ status }), - applyServerMove: (fen, san, color, status) => { - chessEngine.load(fen); - set((state) => ({ - position: fen, - status, - activeColor: chessEngine.turn() as Color, - moveHistory: [ - ...state.moveHistory, - { - san, - color, - moveNumber: Math.floor(state.moveHistory.length / 2) + 1, - }, - ], - })); - }, - - tickClock: (color) => - set((state) => ({ - clocks: { - ...state.clocks, - [color]: Math.max(0, state.clocks[color] - 1), - }, - })), - - endGame: (status) => set({ status }), - - reset: () => { - chessEngine.reset(); - set({ - // Resetting Game Slice - position: INITIAL_FEN, - activeColor: "w", - status: "playing", - moveHistory: [], - lastValidation: null, - playerColor: null, - clocks: { w: 600, b: 600 }, - // Resetting Room Slice - phase: "idle", - rooms: [], - currentRoom: null, - }); - }, -}); + reset: () => { + chessEngine.reset(); + set({ + position: INITIAL_FEN, + activeColor: "w", + status: "playing", + moveHistory: [], + lastValidation: null, + playerColor: null, + clocks: { w: 600, b: 600 }, + drawOfferedBy: null, + phase: "idle", + rooms: [], + currentRoom: null, + gameId: null, + }); + }, +}); \ No newline at end of file diff --git a/frontend/src/store/chess/roomSlice.ts b/frontend/src/store/chess/roomSlice.ts index 3b3d7f6..2202ba7 100644 --- a/frontend/src/store/chess/roomSlice.ts +++ b/frontend/src/store/chess/roomSlice.ts @@ -1,19 +1,16 @@ import { StateCreator } from "zustand"; import type { ChessStore, RoomSlice } from "./store.types"; -import type { ChessPhase } from "@/types/chess.type"; +import type { ChessPhase, Room } from "@/types/chess.type"; -export const createRoomSlice: StateCreator = ( - set, -) => ({ - phase: "idle" as ChessPhase, - rooms: [], - currentRoom: null, +export const createRoomSlice: StateCreator = (set) => ({ + phase: "idle" as ChessPhase, + rooms: [], + currentRoom: null, + gameId: null, - setPhase: (phase) => set({ phase }), - - setRooms: (rooms) => set({ rooms }), - - joinRoom: (room) => set({ currentRoom: room, phase: "room" }), - - leaveRoom: () => set({ currentRoom: null, phase: "lobby" }), -}); + setPhase: (phase) => set({ phase }), + setRooms: (rooms) => set({ rooms }), + joinRoom: (room: Room) => set({ currentRoom: room, phase: "room" }), + leaveRoom: () => set({ currentRoom: null, phase: "lobby" }), + setGameId: (gameId) => set({ gameId }), +}); \ No newline at end of file diff --git a/frontend/src/store/chess/socketSlice.ts b/frontend/src/store/chess/socketSlice.ts new file mode 100644 index 0000000..a7cd1b4 --- /dev/null +++ b/frontend/src/store/chess/socketSlice.ts @@ -0,0 +1,160 @@ +import type { StateCreator } from "zustand"; +import type { Socket } from "socket.io-client"; +import type { ChessStore } from "./store.types"; +import type { Color, GameStatus } from "@/types/chess.type"; + +export type SocketSlice = { + socket: Socket | null; + socketId: string | null; + setSocket: (socket: Socket) => void; +}; + +export const createSocketSlice: StateCreator = (set, get) => ({ + socket: null, + socketId: null, + + setSocket: (socket) => { + if (!socket) { + set({ socket: null, socketId: null }); + return; + } + + socket.removeAllListeners(); + set({ socket, socketId: socket.id }); + + // --- ROOM EVENTS --- + + socket.on("room:created", (data: { success: boolean; room: any }) => { + if (!data.success) return; + set({ + currentRoom: { + id: data.room.id, + name: data.room.name, + players: data.room.playerCount ?? 1, + maxPlayers: 2, + timeControl: data.room.timeControl ?? 600, + }, + phase: "room", + }); + }); + + socket.on("room:joined", (data: { success: boolean; room: any }) => { + if (!data.success) return; + set({ + currentRoom: { + id: data.room.id, + name: data.room.name, + players: data.room.playerCount ?? 1, + maxPlayers: 2, + timeControl: data.room.timeControl ?? 600, + }, + phase: "room", + }); + }); + + socket.on("rooms:list", (data: { gameType?: string; rooms: any[] }) => { + const formattedRooms = (data.rooms || []) + .filter((r) => r.gameType === "chess") + .map((r) => ({ + id: r.id, + name: r.name, + players: r.playerCount ?? 0, + maxPlayers: 2 as const, + timeControl: r.timeControl ?? 600, + })); + set({ rooms: formattedRooms }); + }); + + socket.on("player:joined", (data: any) => { + console.log("player:joined received", data); + const room = get().currentRoom; + console.log("current room", room); + if (room) { + set({ currentRoom: { ...room, players: 2 } }); + } + }); + + // --- GAME START --- + + socket.on("chess:gameStart", (data: { playerColor: Color; timeControl: number; gameId: string }) => { + socket.emit("game:join", { gameId: data.gameId, gameType: "chess" }); + get().startGame(data.playerColor, data.timeControl); + get().setGameId(data.gameId); + socket.emit("chess:startClock", { gameId: data.gameId }); + }); + + // --- REAL-TIME MOVES --- + + socket.on("game:move", (data: { gameId: string; game: any; moveData: any }) => { + const game = data.game; + if (!game) return; + + const { playerColor } = get(); + const movingColor: Color = game.move?.color ?? (game.current_turn === "w" ? "b" : "w"); + if (playerColor && movingColor === playerColor) return; + + const statusMap: Record = { + ongoing: "playing", + checkmate: "checkmate", + stalemate: "stalemate", + draw: "draw", + repetition: "draw", + insufficient_material: "draw", + "50_move_rule": "draw", + resigned: "resigned", + }; + const status: GameStatus = statusMap[game.status] ?? "playing"; + const finalStatus = game.is_check && status === "playing" ? "check" : status; + + get().applyServerMove( + game.fen_position, + game.move?.san ?? "", + movingColor, + finalStatus as GameStatus, + ); + }); + + // --- CLOCK SYNC --- + + socket.on("chess:clockSync", (data: { w: number; b: number }) => { + set({ clocks: { w: data.w, b: data.b } }); + }); + + // --- GAME OVER --- + + socket.on("chess:end", (data: { status: string; winner: string | null }) => { + const statusMap: Record = { + checkmate: "checkmate", + stalemate: "stalemate", + draw: "draw", + resigned: "resigned", + timeout: "checkmate", + disconnect: "resigned", + }; + get().endGame(statusMap[data.status] ?? "resigned"); + }); + + // --- DRAW EVENTS --- + + socket.on("chess:draw", (data: { drawStatus: string; offeredBy: number; status: string }) => { + if (data.drawStatus === "pending") { + set({ drawOfferedBy: data.offeredBy }); + } else if (data.drawStatus === "accepted") { + get().endGame("draw"); + set({ drawOfferedBy: null }); + } else if (data.drawStatus === "rejected") { + set({ drawOfferedBy: null }); + } + }); + + // --- ERRORS --- + + socket.on("chess:error", (data: { message: string }) => { + console.error("Chess socket error:", data.message); + }); + + socket.on("room:error", (data: { message: string }) => { + console.error("Room error:", data.message); + }); + }, +}); \ No newline at end of file diff --git a/frontend/src/store/chess/store.types.ts b/frontend/src/store/chess/store.types.ts index abf6109..6aa7097 100644 --- a/frontend/src/store/chess/store.types.ts +++ b/frontend/src/store/chess/store.types.ts @@ -1,43 +1,46 @@ import type { - ChessPhase, - Color, - GameStatus, - MoveEntry, - ValidationResult, - Room, + ChessPhase, + Color, + GameStatus, + MoveEntry, + ValidationResult, + Room, } from "@/types/chess.type"; export interface RoomSlice { - phase: ChessPhase; - rooms: Room[]; - currentRoom: Room | null; - setPhase: (phase: ChessPhase) => void; - setRooms: (rooms: Room[]) => void; - joinRoom: (room: Room) => void; - leaveRoom: () => void; + phase: ChessPhase; + rooms: Room[]; + currentRoom: Room | null; + gameId: string | null; + setPhase: (phase: ChessPhase) => void; + setRooms: (rooms: Room[]) => void; + joinRoom: (room: Room) => void; + leaveRoom: () => void; + setGameId: (gameId: string) => void; } export interface GameSlice { - position: string; - activeColor: Color; - status: GameStatus; - moveHistory: MoveEntry[]; - lastValidation: ValidationResult | null; - playerColor: Color | null; - clocks: { w: number; b: number }; - startGame: (playerColor: Color, timeControl: number) => void; - makeMove: (from: string, to: string, promotion?: string) => void; - setValidation: (result: ValidationResult) => void; - applyServerMove: ( - fen: string, - san: string, - color: Color, - status: GameStatus, - ) => void; - tickClock: (color: Color) => void; - endGame: (status: GameStatus) => void; - reset: () => void; + position: string; + activeColor: Color; + status: GameStatus; + moveHistory: MoveEntry[]; + lastValidation: ValidationResult | null; + playerColor: Color | null; + clocks: { w: number; b: number }; + drawOfferedBy: number | null; + startGame: (playerColor: Color, timeControl: number) => void; + makeMove: (from: string, to: string, promotion?: string) => void; + setValidation: (result: ValidationResult) => void; + applyServerMove: (fen: string, san: string, color: Color, status: GameStatus) => void; + tickClock: (color: Color) => void; + endGame: (status: GameStatus) => void; + reset: () => void; } -// Stitching the interfaces together to make the Master Store Type -export type ChessStore = RoomSlice & GameSlice; +export interface SocketSlice { + socket: import("socket.io-client").Socket | null; + socketId: string | null; + setSocket: (socket: import("socket.io-client").Socket) => void; +} + +export type ChessStore = RoomSlice & GameSlice & SocketSlice; \ No newline at end of file diff --git a/frontend/src/store/chess/useChessStore.ts b/frontend/src/store/chess/useChessStore.ts index 817cfb1..d21a5a7 100644 --- a/frontend/src/store/chess/useChessStore.ts +++ b/frontend/src/store/chess/useChessStore.ts @@ -2,8 +2,10 @@ import { create } from "zustand"; import type { ChessStore } from "./store.types"; import { createRoomSlice } from "./roomSlice"; import { createGameSlice } from "./gameSlice"; +import { createSocketSlice } from "./socketSlice"; export const useChessStore = create()((...a) => ({ - ...createRoomSlice(...a), - ...createGameSlice(...a), -})); + ...createRoomSlice(...a), + ...createGameSlice(...a), + ...createSocketSlice(...a), +})); \ No newline at end of file diff --git a/package.json b/package.json index 9fef2b8..63bec04 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "chess.js": "^1.4.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-icons": "^5.5.0", From 54f84f2184bde30640777e0eeba8d69911e61c18 Mon Sep 17 00:00:00 2001 From: gaganalexa <202310537@fit.edu.ph> Date: Fri, 8 May 2026 01:50:21 +0800 Subject: [PATCH 2/5] feat: update chess game logic and enhance socket event handling --- backend/migrations/007_chess_game.sql | 9 +- .../config/handlers/chess-events.handler.ts | 20 ++- .../config/handlers/game-events.handler.ts | 1 + .../config/handlers/room-events.handler.ts | 26 +++- backend/src/config/socket-server.ts | 2 +- .../src/services/chess/core/game.getstatus.ts | 83 ++++++++--- .../src/services/chess/core/game.makeMove.ts | 74 +++++----- frontend/src/components/chess/GameAlerts.tsx | 52 ++++--- frontend/src/context/SocketContext.tsx | 58 +++++--- frontend/src/lib/chess/chess-utils.ts | 22 +-- frontend/src/store/chess/socketSlice.ts | 133 +++++++++++------- 11 files changed, 301 insertions(+), 179 deletions(-) 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/src/config/handlers/chess-events.handler.ts b/backend/src/config/handlers/chess-events.handler.ts index 23a7be1..35fc85b 100644 --- a/backend/src/config/handlers/chess-events.handler.ts +++ b/backend/src/config/handlers/chess-events.handler.ts @@ -1,3 +1,4 @@ + import { Server, Socket } from "socket.io"; import { RoomManager } from "@/utils/room-manager"; import { ChessService } from "@/services/chess/chess.services"; @@ -35,12 +36,7 @@ function startClockSync(io: Server, gameId: string, getGame: () => Promise) clockIntervals.set(gameId, interval); } -function getUserId(socket: Socket, userSocketMap: Map): number | null { - for (const [uid, sid] of userSocketMap.entries()) { - if (sid === socket.id) return parseInt(uid, 10); - } - return null; -} + export function registerChessEvents( io: Server, @@ -49,7 +45,7 @@ export function registerChessEvents( userSocketMap: Map, ) { socket.on("chess:startClock", (data: { gameId: string }) => { - const userId = getUserId(socket, userSocketMap); + 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}`); @@ -57,7 +53,7 @@ export function registerChessEvents( socket.on("chess:resign", async (data: { gameId: string }) => { try { - const userId = getUserId(socket, userSocketMap); + 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)) { @@ -72,7 +68,7 @@ export function registerChessEvents( socket.on("chess:offerDraw", async (data: { gameId: string }) => { try { - const userId = getUserId(socket, userSocketMap); + const userId = socket.data.userId; if (!userId) { socket.emit("chess:error", { message: "Not authenticated" }); return; } await chessService.offerDraw(data.gameId, userId); } catch (err: any) { @@ -82,7 +78,7 @@ export function registerChessEvents( socket.on("chess:acceptDraw", async (data: { gameId: string }) => { try { - const userId = getUserId(socket, userSocketMap); + 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)) { @@ -96,7 +92,7 @@ export function registerChessEvents( socket.on("chess:declineDraw", async (data: { gameId: string }) => { try { - const userId = getUserId(socket, userSocketMap); + const userId = socket.data.userId; if (!userId) { socket.emit("chess:error", { message: "Not authenticated" }); return; } await chessService.rejectDraw(data.gameId, userId); } catch (err: any) { @@ -105,7 +101,7 @@ export function registerChessEvents( }); socket.on("disconnect", () => { - const userId = getUserId(socket, userSocketMap); + const userId = socket.data.userId; if (!userId) return; const playerRoom = roomManager.getPlayerRoom(socket.id); if (!playerRoom || playerRoom.gameType !== "chess") return; diff --git a/backend/src/config/handlers/game-events.handler.ts b/backend/src/config/handlers/game-events.handler.ts index 8fcee64..25adf46 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"; diff --git a/backend/src/config/handlers/room-events.handler.ts b/backend/src/config/handlers/room-events.handler.ts index 32bb840..1867ac1 100644 --- a/backend/src/config/handlers/room-events.handler.ts +++ b/backend/src/config/handlers/room-events.handler.ts @@ -76,11 +76,24 @@ export function registerRoomEvents(io: Server, socket: Socket, roomManager: Room const [hostSocketId, guestSocketId] = Array.from(room.players); // Resolve userIds from socket IDs - let hostUserId: number | null = null; - let guestUserId: number | null = null; - for (const [uid, sid] of userSocketMap.entries()) { - if (sid === hostSocketId) hostUserId = parseInt(uid, 10); - if (sid === guestSocketId) guestUserId = parseInt(uid, 10); + 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) { @@ -128,4 +141,5 @@ export function registerRoomEvents(io: Server, socket: Socket, roomManager: Room if (roomInfo) { socket.emit("room:info", roomInfo); } else { socket.emit("room:error", { message: "Room not found" }); } }); -} \ No newline at end of file +} + diff --git a/backend/src/config/socket-server.ts b/backend/src/config/socket-server.ts index 2fea341..737778d 100644 --- a/backend/src/config/socket-server.ts +++ b/backend/src/config/socket-server.ts @@ -26,6 +26,7 @@ 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); @@ -53,7 +54,6 @@ export function initializeSocket(server: HTTPServer) { registerGameEvents(io, socket, roomManager, userSocketMap); registerChatEvents(io, socket); registerDisconnectEvents(io, socket, roomManager); - registerChessEvents(io, socket, roomManager, userSocketMap); }); io.on("connect_error", (err) => { 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..7245ab5 100644 --- a/backend/src/services/chess/core/game.makeMove.ts +++ b/backend/src/services/chess/core/game.makeMove.ts @@ -5,41 +5,48 @@ 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!"); } - + // ===================================================== + // ✅ FIX: ONLY USE FEN (NO PGN EVER) + // ===================================================== + 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 +55,30 @@ 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 || null, + promotion: moveResult.promotion || null, + color: moveResult.color, }); - // 4. Update Main Game State - const statusResult = getGameStatus(engine); + // 5. Update game state const updateData = { fen_position: engine.fen(), - pgn_data: engine.pgn(), + pgn_data: engine.pgn(), // optional for history only current_turn: engine.turn(), - is_check: engine.inCheck(), + is_check: engine.isCheck(), winner: statusResult.winner, - status: statusResult.reason + status: 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/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/context/SocketContext.tsx b/frontend/src/context/SocketContext.tsx index 1855a20..e53f8f3 100644 --- a/frontend/src/context/SocketContext.tsx +++ b/frontend/src/context/SocketContext.tsx @@ -15,7 +15,6 @@ type SocketContextType = { socket: Socket | null; socketId: string | null; isConnected: boolean; - // eslint-disable-next-line @typescript-eslint/no-explicit-any rooms: any[]; }; @@ -28,56 +27,69 @@ const SocketContext = createContext({ export function SocketProvider({ children }: { children: ReactNode }) { const socketRef = useRef(null); - const [socket, setSocket] = useState(null); + const [isConnected, setIsConnected] = useState(false); const [socketId, setSocketId] = useState(null); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const [rooms, setRooms] = useState([]); useEffect(() => { - socketRef.current = io(process.env.NEXT_PUBLIC_API_URL!, { + const userId = + localStorage.getItem("userId") || + crypto.randomUUID(); + + localStorage.setItem("userId", userId); + + const socket = io(process.env.NEXT_PUBLIC_API_URL!, { transports: ["websocket"], }); + socketRef.current = socket; + const handleConnect = () => { - setSocket(socketRef.current); setIsConnected(true); - setSocketId(socketRef.current?.id || null); + setSocketId(socket.id ?? null); + + // register identity + socket.emit("user:register", { userId }); }; const handleDisconnect = () => { setIsConnected(false); }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleRoomsList = (data: { gameType?: string; rooms: any[] }) => { + const handleRoomsList = (data: { rooms: any[] }) => { setRooms(data.rooms || []); }; - socketRef.current.on("connect", handleConnect); - socketRef.current.on("disconnect", handleDisconnect); - socketRef.current.on("rooms:list", handleRoomsList); + socket.on("connect", handleConnect); + socket.on("disconnect", handleDisconnect); + socket.on("rooms:list", handleRoomsList); - socketRef.current.on("room:created", (data) => { - console.log("Room created in frontend:", data); + socket.on("room:created", (data) => { + console.log("Room created:", data); }); - socketRef.current.on("room:joined", (data: { roomId: string }) => { - console.log("Room joined in frontend:", data); + socket.on("room:joined", (data) => { + console.log("Room joined:", data); }); return () => { - socketRef.current?.off("connect", handleConnect); - socketRef.current?.off("disconnect", handleDisconnect); - socketRef.current?.off("rooms:list", handleRoomsList); - socketRef.current?.disconnect(); - socketRef.current = null; + socket.off("connect", handleConnect); + socket.off("disconnect", handleDisconnect); + socket.off("rooms:list", handleRoomsList); + + socket.disconnect(); }; }, []); const contextValue = useMemo( - () => ({ socket, socketId, isConnected, rooms }), - [socket, socketId, isConnected, rooms], + () => ({ + socket: socketRef.current, + socketId, + isConnected, + rooms, + }), + [socketId, isConnected, rooms], ); return ( @@ -89,4 +101,4 @@ export function SocketProvider({ children }: { children: ReactNode }) { export function useSocketContext() { return useContext(SocketContext); -} +} \ No newline at end of file diff --git a/frontend/src/lib/chess/chess-utils.ts b/frontend/src/lib/chess/chess-utils.ts index da87517..ae8415a 100644 --- a/frontend/src/lib/chess/chess-utils.ts +++ b/frontend/src/lib/chess/chess-utils.ts @@ -23,39 +23,39 @@ const STARTING_COUNTS: Record = { export function getMaterialAdvantage(fen: string) { const board = fen.split(" ")[0]; - // Count what is currently on the board const counts = { w: { p: 0, n: 0, b: 0, r: 0, q: 0 }, b: { p: 0, n: 0, b: 0, r: 0, q: 0 }, }; + const isPiece = (c: string) => "pnbrq".includes(c); + for (const char of board) { - if (char >= "a" && char <= "z" && char !== "k") { + if (!isPiece(char)) continue; + + if (char === char.toLowerCase()) { counts.b[char as PieceSymbol]++; - } else if (char >= "A" && char <= "Z" && char !== "K") { + } else { counts.w[char.toLowerCase() as PieceSymbol]++; } } - const wCaptured: PieceSymbol[] = []; // Black pieces captured by White - const bCaptured: PieceSymbol[] = []; // White pieces captured by Black + const wCaptured: PieceSymbol[] = []; + const bCaptured: PieceSymbol[] = []; let wScore = 0; let bScore = 0; (Object.keys(STARTING_COUNTS) as PieceSymbol[]).forEach((piece) => { - // Calculate missing pieces const blackLost = STARTING_COUNTS[piece] - counts.b[piece]; for (let i = 0; i < blackLost; i++) wCaptured.push(piece); const whiteLost = STARTING_COUNTS[piece] - counts.w[piece]; for (let i = 0; i < whiteLost; i++) bCaptured.push(piece); - // Add to total on-board scores wScore += counts.w[piece] * PIECE_VALUES[piece]; bScore += counts.b[piece] * PIECE_VALUES[piece]; }); - // Sort the captured pieces so they look neat: Queens first, Pawns last const sortOrder = { q: 1, r: 2, b: 3, n: 4, p: 5 }; wCaptured.sort((a, b) => sortOrder[a] - sortOrder[b]); bCaptured.sort((a, b) => sortOrder[a] - sortOrder[b]); @@ -70,9 +70,13 @@ export const INITIAL_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; export function deriveStatus(engine: Chess): GameStatus { + // IMPORTANT ORDER (prevents false checkmate) if (engine.isCheckmate()) return "checkmate"; if (engine.isStalemate()) return "stalemate"; + if (engine.isThreefoldRepetition()) return "draw"; + if (engine.isInsufficientMaterial()) return "draw"; if (engine.isDraw()) return "draw"; if (engine.isCheck()) return "check"; + return "playing"; -} +} \ No newline at end of file diff --git a/frontend/src/store/chess/socketSlice.ts b/frontend/src/store/chess/socketSlice.ts index a7cd1b4..1929b07 100644 --- a/frontend/src/store/chess/socketSlice.ts +++ b/frontend/src/store/chess/socketSlice.ts @@ -19,13 +19,19 @@ export const createSocketSlice: StateCreator = return; } + // prevent duplicate listeners socket.removeAllListeners(); - set({ socket, socketId: socket.id }); + + set({ + socket, + socketId: socket.id ?? null, + }); // --- ROOM EVENTS --- socket.on("room:created", (data: { success: boolean; room: any }) => { if (!data.success) return; + set({ currentRoom: { id: data.room.id, @@ -40,6 +46,7 @@ export const createSocketSlice: StateCreator = socket.on("room:joined", (data: { success: boolean; room: any }) => { if (!data.success) return; + set({ currentRoom: { id: data.room.id, @@ -62,13 +69,13 @@ export const createSocketSlice: StateCreator = maxPlayers: 2 as const, timeControl: r.timeControl ?? 600, })); + set({ rooms: formattedRooms }); }); socket.on("player:joined", (data: any) => { - console.log("player:joined received", data); const room = get().currentRoom; - console.log("current room", room); + if (room) { set({ currentRoom: { ...room, players: 2 } }); } @@ -76,51 +83,71 @@ export const createSocketSlice: StateCreator = // --- GAME START --- - socket.on("chess:gameStart", (data: { playerColor: Color; timeControl: number; gameId: string }) => { - socket.emit("game:join", { gameId: data.gameId, gameType: "chess" }); - get().startGame(data.playerColor, data.timeControl); - get().setGameId(data.gameId); - socket.emit("chess:startClock", { gameId: data.gameId }); - }); - - // --- REAL-TIME MOVES --- - - socket.on("game:move", (data: { gameId: string; game: any; moveData: any }) => { - const game = data.game; - if (!game) return; + socket.on( + "chess:gameStart", + (data: { playerColor: Color; timeControl: number; gameId: string }) => { + socket.emit("game:join", { + gameId: data.gameId, + gameType: "chess", + }); - const { playerColor } = get(); - const movingColor: Color = game.move?.color ?? (game.current_turn === "w" ? "b" : "w"); - if (playerColor && movingColor === playerColor) return; + get().startGame(data.playerColor, data.timeControl); + get().setGameId(data.gameId); - const statusMap: Record = { - ongoing: "playing", - checkmate: "checkmate", - stalemate: "stalemate", - draw: "draw", - repetition: "draw", - insufficient_material: "draw", - "50_move_rule": "draw", - resigned: "resigned", - }; - const status: GameStatus = statusMap[game.status] ?? "playing"; - const finalStatus = game.is_check && status === "playing" ? "check" : status; - - get().applyServerMove( - game.fen_position, - game.move?.san ?? "", - movingColor, - finalStatus as GameStatus, - ); - }); + socket.emit("chess:startClock", { gameId: data.gameId }); + } + ); + + // --- MOVES --- + + socket.on( + "game:move", + (data: { gameId: string; game: any; moveData: any }) => { + const game = data.game; + if (!game) return; + + const { playerColor } = get(); + + const movingColor: Color = + game.move?.color ?? (game.current_turn === "w" ? "b" : "w"); + + if (playerColor && movingColor === playerColor) return; + + const statusMap: Record = { + ongoing: "playing", + checkmate: "checkmate", + stalemate: "stalemate", + draw: "draw", + repetition: "draw", + insufficient_material: "draw", + "50_move_rule": "draw", + resigned: "resigned", + }; + + const status: GameStatus = + statusMap[game.status] ?? "playing"; + + const finalStatus = + game.is_check && status === "playing" + ? "check" + : status; + + get().applyServerMove( + game.fen_position, + game.move?.san ?? "", + movingColor, + finalStatus as GameStatus + ); + } + ); - // --- CLOCK SYNC --- + // --- CLOCK --- socket.on("chess:clockSync", (data: { w: number; b: number }) => { set({ clocks: { w: data.w, b: data.b } }); }); - // --- GAME OVER --- + // --- GAME END --- socket.on("chess:end", (data: { status: string; winner: string | null }) => { const statusMap: Record = { @@ -131,21 +158,25 @@ export const createSocketSlice: StateCreator = timeout: "checkmate", disconnect: "resigned", }; + get().endGame(statusMap[data.status] ?? "resigned"); }); - // --- DRAW EVENTS --- - - socket.on("chess:draw", (data: { drawStatus: string; offeredBy: number; status: string }) => { - if (data.drawStatus === "pending") { - set({ drawOfferedBy: data.offeredBy }); - } else if (data.drawStatus === "accepted") { - get().endGame("draw"); - set({ drawOfferedBy: null }); - } else if (data.drawStatus === "rejected") { - set({ drawOfferedBy: null }); + // --- DRAW --- + + socket.on( + "chess:draw", + (data: { drawStatus: string; offeredBy: number; status: string }) => { + if (data.drawStatus === "pending") { + set({ drawOfferedBy: data.offeredBy }); + } else if (data.drawStatus === "accepted") { + get().endGame("draw"); + set({ drawOfferedBy: null }); + } else if (data.drawStatus === "rejected") { + set({ drawOfferedBy: null }); + } } - }); + ); // --- ERRORS --- From 927d65168f39100756fadf837d1f795bcb813544 Mon Sep 17 00:00:00 2001 From: gaganalexa <202310537@fit.edu.ph> Date: Fri, 8 May 2026 02:27:32 +0800 Subject: [PATCH 3/5] refactor: update type imports to use centralized types from chess.type --- frontend/src/components/chess/MoveHistory.tsx | 2 +- .../components/chess/MoveValidationToast.tsx | 2 +- .../components/chess/board/TurnIndicator.tsx | 27 ++++++++++++------- 3 files changed, 19 insertions(+), 12 deletions(-) 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 ); -} +} \ No newline at end of file diff --git a/frontend/src/components/chess/screens/GameScreen.tsx b/frontend/src/components/chess/screens/GameScreen.tsx index ff7d84a..6a192e4 100644 --- a/frontend/src/components/chess/screens/GameScreen.tsx +++ b/frontend/src/components/chess/screens/GameScreen.tsx @@ -40,6 +40,9 @@ export default function GameScreen() { const orientation = playerColor ?? "w"; const opponentColor = orientation === "w" ? "b" : "w"; + console.log("orientation:", orientation, "material:", material); + console.log("position:", position); + const isGameOver = status === "checkmate" || status === "stalemate" || @@ -101,11 +104,11 @@ export default function GameScreen() {
- + capturedColor={orientation} + />
@@ -151,9 +154,9 @@ export default function GameScreen() {
diff --git a/frontend/src/context/SocketContext.tsx b/frontend/src/context/SocketContext.tsx index e53f8f3..9f2ae28 100644 --- a/frontend/src/context/SocketContext.tsx +++ b/frontend/src/context/SocketContext.tsx @@ -33,14 +33,12 @@ export function SocketProvider({ children }: { children: ReactNode }) { const [rooms, setRooms] = useState([]); useEffect(() => { - const userId = - localStorage.getItem("userId") || - crypto.randomUUID(); - + const userId = localStorage.getItem("userId") ?? crypto.randomUUID(); localStorage.setItem("userId", userId); const socket = io(process.env.NEXT_PUBLIC_API_URL!, { transports: ["websocket"], + query: { userId }, }); socketRef.current = socket; diff --git a/frontend/src/hooks/chess/useChessSocket.ts b/frontend/src/hooks/chess/useChessSocket.ts index 2b38a68..c40c91f 100644 --- a/frontend/src/hooks/chess/useChessSocket.ts +++ b/frontend/src/hooks/chess/useChessSocket.ts @@ -14,6 +14,7 @@ export function useChessSocket() { useEffect(() => { if (!socket) return; if (storeSocket === socket) return; // already set, don't re-register listeners + console.log("setSocket called"); setSocket(socket); }, [socket, setSocket, storeSocket]); } \ No newline at end of file diff --git a/frontend/src/lib/chess/chess-utils.ts b/frontend/src/lib/chess/chess-utils.ts index ae8415a..9116220 100644 --- a/frontend/src/lib/chess/chess-utils.ts +++ b/frontend/src/lib/chess/chess-utils.ts @@ -20,25 +20,19 @@ const STARTING_COUNTS: Record = { q: 1, }; -export function getMaterialAdvantage(fen: string) { - const board = fen.split(" ")[0]; - - const counts = { - w: { p: 0, n: 0, b: 0, r: 0, q: 0 }, - b: { p: 0, n: 0, b: 0, r: 0, q: 0 }, - }; - - const isPiece = (c: string) => "pnbrq".includes(c); - - for (const char of board) { - if (!isPiece(char)) continue; - - if (char === char.toLowerCase()) { - counts.b[char as PieceSymbol]++; - } else { - counts.w[char.toLowerCase() as PieceSymbol]++; + export function getMaterialAdvantage(fen: string) { + const board = fen.split(" ")[0]; + const counts = { w: { p: 0, n: 0, b: 0, r: 0, q: 0 }, b: { p: 0, n: 0, b: 0, r: 0, q: 0 } }; + const isPiece = (c: string) => "pnbrq".includes(c); + for (const char of board) { + if (!isPiece(char.toLowerCase())) continue; + if (char === char.toLowerCase()) { + counts.b[char as PieceSymbol]++; + } else { + counts.w[char.toLowerCase() as PieceSymbol]++; + } } - } + console.log("counts:", JSON.stringify(counts)); const wCaptured: PieceSymbol[] = []; const bCaptured: PieceSymbol[] = []; diff --git a/frontend/src/store/chess/gameSlice.ts b/frontend/src/store/chess/gameSlice.ts index dd232f4..6b2ad2a 100644 --- a/frontend/src/store/chess/gameSlice.ts +++ b/frontend/src/store/chess/gameSlice.ts @@ -32,30 +32,42 @@ export const createGameSlice: StateCreator = (set }, makeMove: (from, to, promotion) => { - const state = get(); - if (state.status !== "playing" && state.status !== "check") return; - try { - const move = chessEngine.move({ from, to, promotion }); - if (!move) { - set({ lastValidation: { valid: false, reason: "Illegal move" } }); - return; - } - const status = deriveStatus(chessEngine); - const moveNumber = Math.floor(state.moveHistory.length / 2) + 1; - set({ - position: chessEngine.fen(), - activeColor: chessEngine.turn() as Color, - status, - lastValidation: { valid: true }, - moveHistory: [ - ...state.moveHistory, - { san: move.san, color: move.color as Color, moveNumber }, - ], - }); - } catch { + console.log("makeMove called", from, to, "playerColor:", get().playerColor, "activeColor:", get().activeColor); + + const state = get(); + if (state.status !== "playing" && state.status !== "check") return; + try { + const move = chessEngine.move({ from, to, promotion }); + if (!move) { set({ lastValidation: { valid: false, reason: "Illegal move" } }); + return; } - }, + const status = deriveStatus(chessEngine); + const moveNumber = Math.floor(state.moveHistory.length / 2) + 1; + set({ + position: chessEngine.fen(), + activeColor: chessEngine.turn() as Color, + status, + lastValidation: { valid: true }, + moveHistory: [ + ...state.moveHistory, + { san: move.san, color: move.color as Color, moveNumber }, + ], + }); + + // Emit move to server + const { socket, gameId, playerColor } = get(); + if (socket && gameId && playerColor === move.color) { + socket.emit("game:move", { + gameId, + gameType: "chess", + moveData: { from, to, promotion }, + }); + } + } catch { + set({ lastValidation: { valid: false, reason: "Illegal move" } }); + } +}, setValidation: (result) => set({ lastValidation: result }), diff --git a/frontend/src/store/chess/socketSlice.ts b/frontend/src/store/chess/socketSlice.ts index 1929b07..a362d52 100644 --- a/frontend/src/store/chess/socketSlice.ts +++ b/frontend/src/store/chess/socketSlice.ts @@ -20,7 +20,7 @@ export const createSocketSlice: StateCreator = } // prevent duplicate listeners - socket.removeAllListeners(); + socket.off(); set({ socket, @@ -103,16 +103,24 @@ export const createSocketSlice: StateCreator = socket.on( "game:move", (data: { gameId: string; game: any; moveData: any }) => { + console.log("game:move received", data.game?.status, data.game?.is_check); const game = data.game; if (!game) return; const { playerColor } = get(); const movingColor: Color = - game.move?.color ?? (game.current_turn === "w" ? "b" : "w"); + game.move?.color ?? (game.current_turn === "w" ? "b" : "w"); if (playerColor && movingColor === playerColor) return; + console.log("game:move debug", { + playerColor: get().playerColor, + movingColor, + moveColor: game.move?.color, + currentTurn: game.current_turn, + }); + const statusMap: Record = { ongoing: "playing", checkmate: "checkmate", From 83f107c871252f3a0ef009e28e1d7c1d49275757 Mon Sep 17 00:00:00 2001 From: gaganalexa <202310537@fit.edu.ph> Date: Fri, 8 May 2026 11:47:50 +0800 Subject: [PATCH 5/5] doc: complete chess real-time features and UI components setup --- backend/TASK.md | 10 +++++----- frontend/TASK.md | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) 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/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