diff --git a/apps/client/src/components/Timer.tsx b/apps/client/src/components/Timer.tsx index cb29802..a6dda77 100644 --- a/apps/client/src/components/Timer.tsx +++ b/apps/client/src/components/Timer.tsx @@ -2,21 +2,13 @@ import { cn } from "@utils/cn"; interface TimerType { side: "w" | "b"; - whiteDisplayTime: number; - blackDisplayTime: number; + displayTime?: number; currentTurn: "w" | "b"; playerName: string | undefined; } -export function Timer({ - side, - blackDisplayTime, - whiteDisplayTime, - currentTurn, - playerName = "Random Player", -}: TimerType) { +export function Timer({ side, displayTime, currentTurn, playerName = "Random Player" }: TimerType) { const isActive = side === currentTurn; - const displayTime = side === "w" ? whiteDisplayTime : blackDisplayTime; return (
@@ -24,16 +16,18 @@ export function Timer({ {playerName}
-
- - {formatTime(displayTime)} - -
+ {displayTime !== undefined && ( +
+ + {formatTime(displayTime)} + +
+ )}
); } diff --git a/apps/client/src/components/chessSidebar.tsx b/apps/client/src/components/chessSidebar.tsx index 07a48a0..cb78cc2 100644 --- a/apps/client/src/components/chessSidebar.tsx +++ b/apps/client/src/components/chessSidebar.tsx @@ -8,7 +8,7 @@ export default function SideBar({ gameState: GameStateEvent | undefined; }) { return ( -
+
{opponent && (
diff --git a/apps/client/src/components/profile/deleteAccountBtn.tsx b/apps/client/src/components/profile/deleteAccountBtn.tsx index 031eab5..58fed4f 100644 --- a/apps/client/src/components/profile/deleteAccountBtn.tsx +++ b/apps/client/src/components/profile/deleteAccountBtn.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; import { useApi } from "@hooks/useApi"; import { useNavigate } from "react-router"; diff --git a/apps/client/src/components/profile/logoutBtn.tsx b/apps/client/src/components/profile/logoutBtn.tsx index f1e3302..ab9ef00 100644 --- a/apps/client/src/components/profile/logoutBtn.tsx +++ b/apps/client/src/components/profile/logoutBtn.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router"; import { useApi } from "@hooks/useApi"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; export function LogoutBtn() { const { logout } = useAuth(); diff --git a/apps/client/src/hooks/useAuth.ts b/apps/client/src/hooks/auth/useAuth.ts similarity index 100% rename from apps/client/src/hooks/useAuth.ts rename to apps/client/src/hooks/auth/useAuth.ts diff --git a/apps/client/src/hooks/useAuthForm.ts b/apps/client/src/hooks/auth/useAuthForm.ts similarity index 97% rename from apps/client/src/hooks/useAuthForm.ts rename to apps/client/src/hooks/auth/useAuthForm.ts index 0486196..b63e5e1 100644 --- a/apps/client/src/hooks/useAuthForm.ts +++ b/apps/client/src/hooks/auth/useAuthForm.ts @@ -1,6 +1,6 @@ import { useNavigate } from "react-router"; import useAuth from "./useAuth"; -import { useApi } from "./useApi"; +import { useApi } from "@hooks/useApi"; import { useState } from "react"; import type { LoginResponse, RegisterResponse } from "@chesslab/shared/types"; import { toErrorMessage } from "@chesslab/shared/errors"; diff --git a/apps/client/src/hooks/game/useChessGame.ts b/apps/client/src/hooks/game/useChessGame.ts new file mode 100644 index 0000000..ad5aa5b --- /dev/null +++ b/apps/client/src/hooks/game/useChessGame.ts @@ -0,0 +1,158 @@ +import type { + GameMoveAck, + GameStartedEvent, + GameStateEvent, + GameSync, + MoveMadeEvent, + PlayerColor, + PlayerJoinedEvent, + TimeInfo, +} from "@chesslab/shared/types"; +import { Chess } from "chess.js"; +import { useCallback, useEffect, useState } from "react"; +import { useSocket } from "../useSocket"; +import type { ChessboardOptions, PieceDropHandlerArgs } from "react-chessboard"; +import useTimer from "./useTimer"; + +export function useChessGame(gameId?: string) { + const { socket } = useSocket(); + + const [chessGame] = useState(() => new Chess()); + const [chessPosition, setChessPosition] = useState(() => chessGame.fen()); + const [gameOverInfo, setGameOverInfo] = useState(); + const [opponentId, setOpponentId] = useState(); + const [color, setColor] = useState("w"); + const [turn, setTurn] = useState("w"); + const [isGameStarted, setGameStarted] = useState(false); + const [timeInfo, setTimeInfo] = useState(); + + const { whiteTimeMs, blackTimeMs } = useTimer({ + turn, + gameOverInfo, + timeInfo, + }); + + const handleMove = useCallback( + ({ fen, turn, timeInfo }: MoveMadeEvent) => { + chessGame.load(fen); + setChessPosition(fen); + setTurn(turn); + setTimeInfo(timeInfo); + }, + [chessGame], + ); + + useEffect(() => { + if (!gameId) return; + + function handleGameOver(gameOverInfo: GameStateEvent) { + setGameOverInfo(gameOverInfo); + } + + function handleOpponentJoined({ opponentId }: PlayerJoinedEvent) { + setOpponentId(opponentId); + } + + function handleGameStarted({ timeInfo }: GameStartedEvent) { + setGameStarted(true); + setTimeInfo(timeInfo); + } + + socket.on("game:game-over", handleGameOver); + socket.on("game:move-made", handleMove); + socket.on("game:game-started", handleGameStarted); + socket.on("game:player-joined", handleOpponentJoined); + socket.emit("game:sync", (res: GameSync) => { + if (!res.ok) { + console.error("game:sync failed:", res.error); + return; + } + setColor(res.color); + setOpponentId(res.opponentId); + + handleMove(res); + if (res.opponentId) { + setGameStarted(true); + } + }); + + return () => { + socket.off("game:move-made", handleMove); + socket.off("game:game-over", handleGameOver); + socket.off("game:game-started", handleGameStarted); + socket.off("game:player-joined", handleOpponentJoined); + }; + }, [socket, chessGame, gameId, handleMove]); + + function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { + if (!targetSquare || gameOverInfo?.gameOver || !isGameStarted) { + return false; + } + + const chess = chessGame; + + // Local turn check — avoid emitting when it's not our turn + if (chess.turn() !== color) { + return false; + } + + const previousFen = chess.fen(); + + try { + const move = chess.move({ + from: sourceSquare, + to: targetSquare, + promotion: "q", + }); + if (!move) return false; + } catch { + // chess.js throws on illegal moves + return false; + } + + // Optimistic UI: show the move immediately + setChessPosition(chess.fen()); + setTurn(chess.turn()); + + function fallbackMove() { + chess.load(previousFen); + setChessPosition(previousFen); + setTurn(chess.turn()); + } + + socket.emit( + "game:move", + { + promotion: "q", + from: sourceSquare, + to: targetSquare, + }, + (data: GameMoveAck) => { + if (!data?.ok) { + fallbackMove(); + return; + } + handleMove(data); + }, + ); + + return true; + } + + const chessboardOptions: ChessboardOptions = { + position: chessPosition, + onPieceDrop, + id: gameId ? `board-${gameId}` : "board", + boardOrientation: color === "w" ? "white" : "black", + }; + + return { + color, + opponentId, + gameOverInfo, + whiteTimeMs, + blackTimeMs, + turn, + chessboardOptions, + }; +} diff --git a/apps/client/src/hooks/game/useCompTimer.ts b/apps/client/src/hooks/game/useCompTimer.ts new file mode 100644 index 0000000..6435a6d --- /dev/null +++ b/apps/client/src/hooks/game/useCompTimer.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; + +export const START_TIME_MS = 60 * 10 * 1000; // 10 minutes, 600_000 ms + +interface UseCompTimerProps { + turn: "w" | "b"; + side: "w" | "b"; + isGameOver: boolean; +} + +export function useCompTimer({ turn, side, isGameOver }: UseCompTimerProps) { + const [timeMs, setTimeMs] = useState(START_TIME_MS); + useEffect(() => { + if (isGameOver || turn !== side) return; + + let previousTime = Date.now(); + + const interval = setInterval(() => { + const now = Date.now(); + const elapsed = now - previousTime; + previousTime = now; + + setTimeMs((prev) => Math.max(0, prev - elapsed)); + }, 100); + + return () => clearInterval(interval); + }, [side, turn, isGameOver]); + + return { timeMs, isTimeUp: timeMs <= 0 }; +} diff --git a/apps/client/src/hooks/game/useComputerGame.ts b/apps/client/src/hooks/game/useComputerGame.ts new file mode 100644 index 0000000..453f4b6 --- /dev/null +++ b/apps/client/src/hooks/game/useComputerGame.ts @@ -0,0 +1,79 @@ +import type { ChessboardOptions, PieceDropHandlerArgs } from "react-chessboard"; +import useStockfish from "./useStockfish"; +import { Chess } from "chess.js"; +import { useState } from "react"; +import { getGameOverInfo } from "@chesslab/shared/utils"; +import { useCompTimer } from "./useCompTimer"; + +export function useComputerGame() { + const [chessGame] = useState(() => new Chess()); + const [chessPosition, setChessPosition] = useState(() => new Chess().fen()); + const [turn, setTurn] = useState<"w" | "b">("w"); + const [gameHistory, setGameHistory] = useState([]); + const [side] = useState<"w" | "b">(() => (Math.random() < 0.5 ? "w" : "b")); + + let gameOverInfo = getGameOverInfo(chessGame); + + const { timeMs, isTimeUp } = useCompTimer({ + turn, + side, + isGameOver: !!gameOverInfo, + }); + + if (isTimeUp && !gameOverInfo) { + gameOverInfo = { + gameOver: true, + winnerColor: turn === "w" ? "b" : "w", + reason: "Timeout", + }; + } + + useStockfish({ + chessGame, + turn, + stockfishSide: side === "w" ? "b" : "w", + isGameOver: !!gameOverInfo, + onMove: () => { + setChessPosition(chessGame.fen()); + setTurn(chessGame.turn()); + setGameHistory(chessGame.history()); + }, + }); + + function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { + if (turn !== side || !targetSquare || gameOverInfo) return false; + + try { + const move = chessGame.move({ + from: sourceSquare, + to: targetSquare, + promotion: "q", + }); + + if (!move) return false; + + setChessPosition(chessGame.fen()); + setTurn(chessGame.turn()); + setGameHistory(chessGame.history()); + + return true; + } catch { + return false; + } + } + + const chessboardOptions: ChessboardOptions = { + position: chessPosition, + onPieceDrop, + boardOrientation: side === "w" ? "white" : "black", + }; + + return { + turn, + side, + timeMs, + gameHistory, + gameOverInfo, + chessboardOptions, + }; +} diff --git a/apps/client/src/hooks/useCreateGame.ts b/apps/client/src/hooks/game/useCreateGame.ts similarity index 100% rename from apps/client/src/hooks/useCreateGame.ts rename to apps/client/src/hooks/game/useCreateGame.ts diff --git a/apps/client/src/hooks/useJoinGame.ts b/apps/client/src/hooks/game/useJoinGame.ts similarity index 100% rename from apps/client/src/hooks/useJoinGame.ts rename to apps/client/src/hooks/game/useJoinGame.ts diff --git a/apps/client/src/hooks/useStockfish.ts b/apps/client/src/hooks/game/useStockfish.ts similarity index 85% rename from apps/client/src/hooks/useStockfish.ts rename to apps/client/src/hooks/game/useStockfish.ts index 7e7fa2b..aa2dd37 100644 --- a/apps/client/src/hooks/useStockfish.ts +++ b/apps/client/src/hooks/game/useStockfish.ts @@ -6,11 +6,13 @@ export default function useStockfish({ chessGame, turn, stockfishSide, + isGameOver, onMove, }: { chessGame: Chess; turn: "w" | "b"; stockfishSide: "w" | "b"; + isGameOver: boolean; onMove: () => void; }) { useEffect(() => { @@ -25,7 +27,7 @@ export default function useStockfish({ const bestMove = parts[1]; if (bestMove && bestMove !== "(none)") { try { - if (chessGame.isGameOver()) return; + if (isGameOver) return; chessGame.move({ from: bestMove.substring(0, 2), to: bestMove.substring(2, 4), @@ -43,12 +45,12 @@ export default function useStockfish({ stockfish.postMessage("stop"); stockfish.onmessage = null; }; - }, [chessGame, onMove]); + }, [chessGame, onMove, isGameOver]); useEffect(() => { - if (turn === stockfishSide && !chessGame.isGameOver()) { + if (turn === stockfishSide && !isGameOver) { stockfish.postMessage(`position fen ${chessGame.fen()}`); stockfish.postMessage("go depth 15"); } - }, [turn, chessGame, stockfishSide]); + }, [turn, chessGame, stockfishSide, isGameOver]); } diff --git a/apps/client/src/hooks/game/useTimer.ts b/apps/client/src/hooks/game/useTimer.ts new file mode 100644 index 0000000..0607f52 --- /dev/null +++ b/apps/client/src/hooks/game/useTimer.ts @@ -0,0 +1,38 @@ +import type { GameStateEvent, TimeInfo } from "@chesslab/shared/types"; +import { useEffect, useState } from "react"; + +export const START_TIME_MS = 60 * 10 * 1000; // 10 minutes, 600_000 ms +export default function useTimer({ + turn, + gameOverInfo, + timeInfo, +}: { + turn: "w" | "b"; + gameOverInfo: GameStateEvent | undefined; + timeInfo: TimeInfo | undefined; +}) { + const [whiteTimeMs, setWhiteTimeMs] = useState(START_TIME_MS); + const [blackTimeMs, setBlackTimeMs] = useState(START_TIME_MS); + + useEffect(() => { + if (gameOverInfo?.gameOver || !timeInfo) return; + + const interval = setInterval(() => { + const elapsed = timeInfo.lastMoveTime != null ? Date.now() - timeInfo.lastMoveTime : 0; + + setWhiteTimeMs( + turn === "w" ? Math.max(0, timeInfo.whiteTimeMs - elapsed) : timeInfo.whiteTimeMs, + ); + setBlackTimeMs( + turn === "b" ? Math.max(0, timeInfo.blackTimeMs - elapsed) : timeInfo.blackTimeMs, + ); + }, 250); + + return () => clearInterval(interval); + }, [turn, gameOverInfo, timeInfo]); + + return { + whiteTimeMs, + blackTimeMs, + }; +} diff --git a/apps/client/src/hooks/useApi.ts b/apps/client/src/hooks/useApi.ts index 10f651a..0cbddb9 100644 --- a/apps/client/src/hooks/useApi.ts +++ b/apps/client/src/hooks/useApi.ts @@ -1,5 +1,5 @@ import { SERVER_URL } from "@/config"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@/hooks/auth/useAuth"; export function useApi() { const { accessToken, refresh, logout } = useAuth(); diff --git a/apps/client/src/hooks/useFetchUser.ts b/apps/client/src/hooks/useFetchUser.ts index 7d21b8b..e6f4793 100644 --- a/apps/client/src/hooks/useFetchUser.ts +++ b/apps/client/src/hooks/useFetchUser.ts @@ -1,7 +1,7 @@ import type { User } from "@chesslab/shared/types"; import { useEffect, useState } from "react"; import { useApi } from "@hooks/useApi"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@/hooks/auth/useAuth"; export default function useFetchUser() { const [user, setUser] = useState(undefined); diff --git a/apps/client/src/hooks/useTimer.ts b/apps/client/src/hooks/useTimer.ts deleted file mode 100644 index 0cbe41e..0000000 --- a/apps/client/src/hooks/useTimer.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { GameOverInfo } from "@chesslab/shared/types"; -import { useEffect, useState } from "react"; - -export default function useTimer({ - turn, - gameOverInfo, -}: { - turn: "w" | "b"; - gameOverInfo: GameOverInfo | undefined; -}) { - const [whiteDisplayTime, setWhiteDisplayTime] = useState(600_000); - const [blackDisplayTime, setBlackDisplayTime] = useState(600_000); - - useEffect(() => { - if (gameOverInfo) return; - let interval: NodeJS.Timeout | null = null; - - if (turn === "w") { - interval = setInterval(() => { - setWhiteDisplayTime((prev) => Math.max(0, prev - 1000)); - }, 1000); - } else { - interval = setInterval(() => { - setBlackDisplayTime((prev) => Math.max(0, prev - 1000)); - }, 1000); - } - - return () => { - if (interval) clearInterval(interval); - }; - }, [turn, gameOverInfo]); - - return { whiteDisplayTime, blackDisplayTime }; -} diff --git a/apps/client/src/layouts/NotLoggedIn.tsx b/apps/client/src/layouts/NotLoggedIn.tsx index f3cfc4a..a0498d5 100644 --- a/apps/client/src/layouts/NotLoggedIn.tsx +++ b/apps/client/src/layouts/NotLoggedIn.tsx @@ -1,5 +1,5 @@ import { Navigate, Outlet, useLocation, type Location } from "react-router"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; export default function NotLoggedIn() { const { accessToken, isInitializing } = useAuth(); diff --git a/apps/client/src/layouts/RequireAuth.tsx b/apps/client/src/layouts/RequireAuth.tsx index e9664a2..d733265 100644 --- a/apps/client/src/layouts/RequireAuth.tsx +++ b/apps/client/src/layouts/RequireAuth.tsx @@ -1,5 +1,5 @@ import { Navigate, Outlet, useLocation } from "react-router"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; export default function RequireAuth() { const { accessToken, isInitializing } = useAuth(); diff --git a/apps/client/src/layouts/SocketProvider.tsx b/apps/client/src/layouts/SocketProvider.tsx index 3eb6f24..cfa979e 100644 --- a/apps/client/src/layouts/SocketProvider.tsx +++ b/apps/client/src/layouts/SocketProvider.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type PropsWithChildren } from "react"; -import useAuth from "@hooks/useAuth"; +import useAuth from "@hooks/auth/useAuth"; import { io, type Socket } from "socket.io-client"; import { SERVER_URL } from "@/config"; diff --git a/apps/client/src/pages/computerGame.tsx b/apps/client/src/pages/computerGame.tsx index 003e6b6..668c2a9 100644 --- a/apps/client/src/pages/computerGame.tsx +++ b/apps/client/src/pages/computerGame.tsx @@ -1,108 +1,17 @@ -import { type ChessboardOptions, type PieceDropHandlerArgs } from "react-chessboard"; -import { useState } from "react"; -import { Chess } from "chess.js"; import ChessBoard from "@components/chessBoard"; -import useStockfish from "@hooks/useStockfish"; -import useTimer from "@hooks/useTimer"; import { Timer } from "@components/Timer"; -//import SideBar from "@components/chessSidebar"; -import type { GameOverInfo } from "@chesslab/shared/types"; +import SideBar from "@components/chessSidebar"; +import { useComputerGame } from "@hooks/game/useComputerGame"; export default function ComputerChessBoard() { - const [chessGame] = useState(() => new Chess()); - const [chessPosition, setChessPosition] = useState(() => new Chess().fen()); - const [turn, setTurn] = useState<"w" | "b">("w"); - const [, setGameHistory] = useState([]); - const gameOverInfo = getGameOverInfo(chessGame); - const [side] = useState<"w" | "b">(() => (Math.random() < 0.5 ? "w" : "b")); - - const { whiteDisplayTime, blackDisplayTime } = useTimer({ - turn, - gameOverInfo, - }); - - useStockfish({ - chessGame, - turn, - stockfishSide: side === "w" ? "b" : "w", - onMove: () => { - setChessPosition(chessGame.fen()); - setTurn(chessGame.turn()); - setGameHistory(chessGame.history()); - }, - }); - - function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { - if (chessGame.isGameOver()) return false; - - if (!targetSquare) { - return false; - } - - try { - const move = chessGame.move({ - from: sourceSquare, - to: targetSquare, - promotion: "q", - }); - - if (!move) return false; - - setChessPosition(chessGame.fen()); - setTurn(chessGame.turn()); - setGameHistory(chessGame.history()); - - return true; - } catch { - return false; - } - } - - const chessboardOptions: ChessboardOptions = { - position: chessPosition!, - onPieceDrop, - boardOrientation: side === "w" ? "white" : "black", - }; + const { turn, side, timeMs, gameOverInfo, chessboardOptions } = useComputerGame(); return (
- + - - {/**/} + +
); } - -function getGameOverInfo(chess: Chess): GameOverInfo | undefined { - if (!chess.isGameOver()) return undefined; - - if (chess.isCheckmate()) { - return { - reason: "Checkmate", - winner: chess.turn() === "w" ? "b" : "w", - }; - } else if (chess.isStalemate()) { - return { reason: "Stalemate", winner: "d" }; - } else if (chess.isInsufficientMaterial()) { - return { reason: "Insufficient Material", winner: "d" }; - } else if (chess.isThreefoldRepetition()) { - return { reason: "Threefold Repetition", winner: "d" }; - } else if (chess.isDrawByFiftyMoves()) { - return { reason: "Fifty-Move Rule", winner: "d" }; - } else { - return { reason: "Draw", winner: "d" }; - } -} diff --git a/apps/client/src/pages/homePage.tsx b/apps/client/src/pages/homePage.tsx index 18344e7..6cb13ae 100644 --- a/apps/client/src/pages/homePage.tsx +++ b/apps/client/src/pages/homePage.tsx @@ -2,8 +2,8 @@ import { useNavigate } from "react-router"; import { routes } from "@/routes"; import type React from "react"; import { useState } from "react"; -import useCreateGame from "@hooks/useCreateGame"; -import useJoinGame from "@hooks/useJoinGame"; +import useCreateGame from "@hooks/game/useCreateGame"; +import useJoinGame from "@hooks/game/useJoinGame"; import TrendingUpIcon from "@icons/TrendingUpIcon"; import RookIcon from "@icons/RookIcon"; import RocketIcon from "@icons/RocketIcon"; diff --git a/apps/client/src/pages/loginPage.tsx b/apps/client/src/pages/loginPage.tsx index 7fc7269..afd630f 100644 --- a/apps/client/src/pages/loginPage.tsx +++ b/apps/client/src/pages/loginPage.tsx @@ -1,5 +1,5 @@ import AuthLayout from "@layouts/AuthLayout"; -import useAuthForm from "@hooks/useAuthForm"; +import useAuthForm from "@hooks/auth/useAuthForm"; import { AuthFormContainer } from "@components/AuthFormContainer"; import { AuthFormItem } from "@components/AuthFormItem"; diff --git a/apps/client/src/pages/playerGame.tsx b/apps/client/src/pages/playerGame.tsx index 4c03e17..6b6e2b7 100644 --- a/apps/client/src/pages/playerGame.tsx +++ b/apps/client/src/pages/playerGame.tsx @@ -1,162 +1,32 @@ -import { type ChessboardOptions, type PieceDropHandlerArgs } from "react-chessboard"; -import { Chess } from "chess.js"; -import { useEffect, useState } from "react"; import { useParams } from "react-router"; -import type { - GameStateEvent, - GameMoveAck, - MoveMadeEvent, - PlayerJoinedEvent, - PlayerColor, - GameSync, -} from "@chesslab/shared/types"; import ChessBoard from "@components/chessBoard"; import SideBar from "@components/chessSidebar"; -import { useSocket } from "@hooks/useSocket"; +import { useChessGame } from "@hooks/game/useChessGame"; +import { Timer } from "@components/Timer"; +import useUser from "@hooks/useUser"; export default function PlayerGame() { const { roomId: gameId } = useParams(); - const { socket } = useSocket(); - - const [chessGame] = useState(new Chess()); - const [chessPosition, setChessPosition] = useState(() => chessGame.fen()); - const [gameOverInfo, setGameOverInfo] = useState(); - const [opponentId, setOpponentId] = useState(); - const [color, setColor] = useState(); - const [, setTurn] = useState(); - const [isGameStarted, setGameStarted] = useState(false); - - useEffect(() => { - if (!gameId) return; - - function handleMove({ fen, turn }: MoveMadeEvent) { - chessGame.load(fen); - setChessPosition(fen); - setTurn(turn); - } - - function handleGameOver(gameOverInfo: GameStateEvent) { - setGameOverInfo(gameOverInfo); - } - - function handleOpponentJoined({ opponentId }: PlayerJoinedEvent) { - setOpponentId(opponentId); - } - - function handleGameStarted() { - setGameStarted(true); - } - - socket.on("game:game-over", handleGameOver); - socket.on("game:game-move", handleMove); - socket.on("game:move-made", handleMove); - socket.on("game:game-started", handleGameStarted); - socket.on("game:player-joined", handleOpponentJoined); - socket.emit("game:sync", (res: GameSync) => { - if (!res.ok) { - console.error("game:sync failed:", res.error); - return; - } - setColor(res.color); - setOpponentId(res.opponentId); - chessGame.load(res.fen); - setChessPosition(res.fen); - setTurn(res.turn); - if (res.opponentId) { - setGameStarted(true); - } - }); - - return () => { - socket.off("game:move-made", handleMove); - socket.off("game:game-move", handleMove); - socket.off("game:game-over", handleGameOver); - socket.off("game:game-started", handleGameStarted); - socket.off("game:player-joined", handleOpponentJoined); - }; - }, [gameId, socket, chessGame]); - - function onPieceDrop({ sourceSquare, targetSquare }: PieceDropHandlerArgs) { - if (!targetSquare || gameOverInfo?.gameOver || !isGameStarted || !color) { - return false; - } - - const chess = chessGame; - - // Local turn check — avoid emitting when it's not our turn - if (chess.turn() !== color) { - return false; - } - - const previousFen = chess.fen(); - - try { - const move = chess.move({ - from: sourceSquare, - to: targetSquare, - promotion: "q", - }); - if (!move) return false; - } catch { - // chess.js throws on illegal moves - return false; - } - - // Optimistic UI: show the move immediately - setChessPosition(chess.fen()); - setTurn(chess.turn()); - - socket.emit( - "game:move", - { - promotion: "q", - from: sourceSquare, - to: targetSquare, - }, - (data: GameMoveAck) => { - if (data.ok) { - chessGame.load(data.fen); - setChessPosition(data.fen); - setTurn(data.turn); - } else { - chess.load(previousFen); - setChessPosition(previousFen); - setTurn(chess.turn()); - console.error("game:move rejected:", data.error); - } - }, - ); - - return true; - } - - const chessboardOptions: ChessboardOptions = { - position: chessPosition, - onPieceDrop, - // react-chessboard uses `#${id}-square-…` in querySelector; CSS ids - // cannot start with a digit, so raw UUIDs (e.g. 94632a9e-…) throw. - id: gameId ? `board-${gameId}` : "board", - boardOrientation: color === "w" ? "white" : "black", - }; + const { color, opponentId, gameOverInfo, blackTimeMs, whiteTimeMs, turn, chessboardOptions } = + useChessGame(gameId); + const { user } = useUser(); return ( <>
- {/**/} + /> - {/**/} + />
diff --git a/apps/client/src/pages/signupPage.tsx b/apps/client/src/pages/signupPage.tsx index c63489d..943ef97 100644 --- a/apps/client/src/pages/signupPage.tsx +++ b/apps/client/src/pages/signupPage.tsx @@ -1,5 +1,5 @@ import AuthLayout from "@layouts/AuthLayout"; -import useAuthForm from "@hooks/useAuthForm"; +import useAuthForm from "@hooks/auth/useAuthForm"; import { AuthFormContainer } from "@components/AuthFormContainer"; import { AuthFormItem } from "@components/AuthFormItem"; diff --git a/apps/server/src/game/game.ts b/apps/server/src/game/game.ts index 912f52d..3336421 100644 --- a/apps/server/src/game/game.ts +++ b/apps/server/src/game/game.ts @@ -1,4 +1,5 @@ import type { GameStateEvent, PromotionPiece } from "@chesslab/shared/types"; +import { getGameOverInfo } from "@chesslab/shared/utils"; import { Chess } from "chess.js"; import EventEmitter from "node:events"; @@ -137,7 +138,7 @@ export class Game extends EventEmitter { this.clearTurnTimers(); if (this.chess.isGameOver()) { - this.evaluateGameOverState(playerId); + this.evaluateGameOverState(); } else { this.scheduleTurnTimer(); } @@ -186,6 +187,14 @@ export class Game extends EventEmitter { return this.blackPlayerId; } + getTimeInfo() { + return { + whiteTimeMs: this.whiteTimeMs, + blackTimeMs: this.blackTimeMs, + lastMoveTime: this.lastMoveTime, + }; + } + resign(playerId: string) { if (!this.isValidPlayerId(playerId)) throw new Error("Invalid Player Id"); @@ -254,27 +263,12 @@ export class Game extends EventEmitter { return playerId === this.whitePlayerId || playerId === this.blackPlayerId; } - private evaluateGameOverState(playerId: string) { - let reason: GameStateEvent["reason"]; - let winnerColor: GameStateEvent["winnerColor"]; - if (this.chess.isCheckmate()) { - reason = "Checkmate"; - winnerColor = this.getColor(playerId); - } else if (this.chess.isDraw()) { - winnerColor = "d"; - if (this.chess.isDrawByFiftyMoves()) reason = "Fifty-Move Rule"; - else if (this.chess.isInsufficientMaterial()) reason = "Insufficient Material"; - else if (this.chess.isStalemate()) reason = "Stalemate"; - else if (this.chess.isThreefoldRepetition()) reason = "Threefold Repetition"; - } + private evaluateGameOverState() { + const gameStateInfo = getGameOverInfo(this.chess); + if (!gameStateInfo) return; + this.GameStateEvent = gameStateInfo; this.clearDisconnectTimeouts(); - - this.GameStateEvent = { - gameOver: true, - reason, - winnerColor, - }; } private assertPlayerCanMove(playerId: string) { diff --git a/apps/server/src/sockets/socket.ts b/apps/server/src/sockets/socket.ts index d9aee5c..b6226b5 100644 --- a/apps/server/src/sockets/socket.ts +++ b/apps/server/src/sockets/socket.ts @@ -10,6 +10,7 @@ import type { PlayerJoinedEvent, GameSync, GameHistoryAck, + GameStartedEvent, } from "@chesslab/shared/types"; import { toErrorMessage } from "@chesslab/shared/errors"; import { io } from "@/io.js"; @@ -83,9 +84,7 @@ io.on("connection", (socket) => { socket.data.gameInfo = { gameId, game }; game.on("game-over", ({ GameStateEvent }) => { - io.to(gameId).emit("game:game-over", { - GameStateEvent, - }); + io.to(gameId).emit("game:game-over", GameStateEvent); }); cb({ @@ -126,19 +125,19 @@ io.on("connection", (socket) => { socket.data.gameInfo = { gameId, game }; game.on("game-over", () => { - io.to(gameId).emit("game:game-over", { - GameStateEvent: game.getGameStateEvent(), - }); + io.to(gameId).emit("game:game-over", game.getGameStateEvent()); }); socket.to(gameId).emit("game:player-joined", { gameId, opponentColor: game.getColor(userId), opponentId: userId, - } as PlayerJoinedEvent); + } satisfies PlayerJoinedEvent); game.start(); - io.to(gameId).emit("game:game-started"); + io.to(gameId).emit("game:game-started", { + timeInfo: game.getTimeInfo(), + } satisfies GameStartedEvent); cb({ ok: true, @@ -167,8 +166,11 @@ io.on("connection", (socket) => { game.move(userId, from, to, promotion); const fen = game.getFEN(); const turn = game.getTurn(); + const timeInfo = game.getTimeInfo(); - socket.to(gameId).emit("game:move-made", { fen, turn } as MoveMadeEvent); + socket + .to(gameId) + .emit("game:move-made", { fen, turn, timeInfo } satisfies MoveMadeEvent); if (game.isGameOver()) { io.to(gameId).emit("game:game-over", game.getGameStateEvent()); @@ -178,6 +180,7 @@ io.on("connection", (socket) => { ok: true, fen, turn, + timeInfo, }); } catch (err) { cb({ @@ -276,6 +279,7 @@ io.on("connection", (socket) => { opponentId: game.getMyOpponent(userId), fen: game.getFEN(), turn: game.getTurn(), + timeInfo: game.getTimeInfo(), }); } catch (err) { cb({ diff --git a/eslint.config.js b/eslint.config.js index 8d64fa9..31a5efd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,7 +13,7 @@ export default defineConfig([ extends: [ js.configs.recommended, tseslint.configs.recommended, - reactHooks.configs.flat.recommended, + reactHooks.configs.flat["recommended-latest"], reactRefresh.configs.vite, eslintConfigPrettier, ], diff --git a/packages/shared/package.json b/packages/shared/package.json index 4199a2b..896e214 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -15,5 +15,8 @@ "type": "module", "devDependencies": { "typescript": "^6.0.2" + }, + "dependencies": { + "chess.js": "^1.4.0" } } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8c54483..1332c7d 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -22,17 +22,6 @@ export type JoinGameAck = gameId?: string; }; -export type GameOverInfo = { - reason: - | "Checkmate" - | "Stalemate" - | "Insufficient Material" - | "Threefold Repetition" - | "Fifty-Move Rule" - | "Draw"; - winner: "w" | "b" | "d"; -}; - export interface GameStateEvent { gameOver: boolean; reason: @@ -57,9 +46,20 @@ export type PlayerJoinedEvent = { opponentId: string; }; +export type TimeInfo = { + whiteTimeMs: number; + blackTimeMs: number; + lastMoveTime: number | undefined; +}; + export type MoveMadeEvent = { fen: string; turn: PlayerColor; + timeInfo: TimeInfo; +}; + +export type GameStartedEvent = { + timeInfo: TimeInfo; }; export type GameMoveAck = @@ -67,6 +67,7 @@ export type GameMoveAck = ok: true; fen: string; turn: PlayerColor; + timeInfo: TimeInfo; } | { ok: false; @@ -81,6 +82,7 @@ export type GameSync = opponentId: string | undefined; fen: string; turn: PlayerColor; + timeInfo: TimeInfo; } | { ok: false; diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts new file mode 100644 index 0000000..56dffc6 --- /dev/null +++ b/packages/shared/src/utils.ts @@ -0,0 +1,25 @@ +import type { GameStateEvent } from "./types.js"; +import type { Chess } from "chess.js"; + +export function getGameOverInfo(chess: Chess): GameStateEvent | undefined { + if (!chess.isGameOver()) return undefined; + + let reason: GameStateEvent["reason"]; + let winnerColor: GameStateEvent["winnerColor"]; + if (chess.isCheckmate()) { + reason = "Checkmate"; + winnerColor = chess.turn() === "w" ? "b" : "w"; + } else if (chess.isDraw()) { + winnerColor = "d"; + if (chess.isStalemate()) reason = "Stalemate"; + else if (chess.isInsufficientMaterial()) reason = "Insufficient Material"; + else if (chess.isThreefoldRepetition()) reason = "Threefold Repetition"; + else if (chess.isDrawByFiftyMoves()) reason = "Fifty-Move Rule"; + } + + return { + gameOver: true, + reason, + winnerColor, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc79f32..c91ad2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,7 +100,7 @@ importers: version: 19.2.17 "@types/react-dom": specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.17) + version: 19.2.4(@types/react@19.2.17) "@vitejs/plugin-react": specifier: ^6.0.3 version: 6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -191,6 +191,10 @@ importers: version: 6.0.3 packages/shared: + dependencies: + chess.js: + specifier: ^1.4.0 + version: 1.4.0 devDependencies: typescript: specifier: ^6.0.2 @@ -1683,10 +1687,10 @@ packages: integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==, } - "@types/react-dom@19.2.3": + "@types/react-dom@19.2.4": resolution: { - integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==, + integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==, } peerDependencies: "@types/react": ^19.2.0 @@ -5250,7 +5254,7 @@ snapshots: "@types/range-parser@1.2.7": {} - "@types/react-dom@19.2.3(@types/react@19.2.17)": + "@types/react-dom@19.2.4(@types/react@19.2.17)": dependencies: "@types/react": 19.2.17