Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 14 additions & 20 deletions apps/client/src/components/Timer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,32 @@ 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 (
<div className="m-2 flex items-center justify-between rounded-lg border border-[#424A35]/30 bg-[#20201E] p-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined">person</span>
<span className="text-lg text-[#E5E2DE]">{playerName}</span>
</div>

<div
className={cn(
"rounded border border-[#424A35] bg-[#2A2A28] px-4 py-2 text-[#E5E2DE]",
{ "bg-[#8cdd12] text-[#203600]": isActive },
)}
>
<span className={cn("font-mono text-lg font-semibold")}>
{formatTime(displayTime)}
</span>
</div>
{displayTime !== undefined && (
<div
className={cn(
"rounded border border-[#424A35] bg-[#2A2A28] px-4 py-2 text-[#E5E2DE]",
{ "bg-[#8cdd12] text-[#203600]": isActive },
)}
>
<span className={cn("font-mono text-lg font-semibold")}>
{formatTime(displayTime)}
</span>
</div>
)}
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/components/chessSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default function SideBar({
gameState: GameStateEvent | undefined;
}) {
return (
<div className="fixed top-0 right-0 hidden h-full w-120 border-l border-[#424A35] bg-[#1C1C1A] p-4 sm:block">
<div className="fixed top-0 right-0 h-full w-120 border-l border-[#424A35] bg-[#1C1C1A] p-4">
Comment thread
MohamedSayed0573 marked this conversation as resolved.
Comment thread
MohamedSayed0573 marked this conversation as resolved.
<div>
{opponent && (
<div>
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/components/profile/deleteAccountBtn.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/components/profile/logoutBtn.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
158 changes: 158 additions & 0 deletions apps/client/src/hooks/game/useChessGame.ts
Original file line number Diff line number Diff line change
@@ -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<GameStateEvent | undefined>();
const [opponentId, setOpponentId] = useState<string | undefined>();
const [color, setColor] = useState<PlayerColor>("w");
const [turn, setTurn] = useState<PlayerColor>("w");
const [isGameStarted, setGameStarted] = useState(false);
const [timeInfo, setTimeInfo] = useState<TimeInfo>();

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;

Comment thread
MohamedSayed0573 marked this conversation as resolved.
function handleGameOver(gameOverInfo: GameStateEvent) {
setGameOverInfo(gameOverInfo);
}
Comment thread
MohamedSayed0573 marked this conversation as resolved.

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);
}
});
Comment thread
MohamedSayed0573 marked this conversation as resolved.

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,
};
}
30 changes: 30 additions & 0 deletions apps/client/src/hooks/game/useCompTimer.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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]);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

return { timeMs, isTimeUp: timeMs <= 0 };
}
Comment thread
MohamedSayed0573 marked this conversation as resolved.
79 changes: 79 additions & 0 deletions apps/client/src/hooks/game/useComputerGame.ts
Original file line number Diff line number Diff line change
@@ -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());
Comment thread
MohamedSayed0573 marked this conversation as resolved.
const [turn, setTurn] = useState<"w" | "b">("w");
const [gameHistory, setGameHistory] = useState<string[]>([]);
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,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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),
Expand All @@ -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]);
}
Loading