diff --git a/README.md b/README.md index 26288a74..dd22179e 100644 --- a/README.md +++ b/README.md @@ -19,19 +19,18 @@ Let's keep it simple ## Setting it up locally - - Clone the repo - - Copy over .env.example over to .env everywhere - - Update .env - - Postgres DB Credentials - - Github/Google Auth credentials - - npm install - - Start ws server - - cd apps/ws - - npm run dev - - Start Backend - - cd apps/backend - - npm run dev - - Start frontend - - cd apps/frontend - - npm run dev - +- Clone the repo +- Copy over .env.example over to .env everywhere +- Update .env + - Postgres DB Credentials + - Github/Google Auth credentials +- npm install +- Start ws server + - cd apps/ws + - npm run dev +- Start Backend + - cd apps/backend + - npm run dev +- Start frontend + - cd apps/frontend + - npm run dev diff --git a/apps/backend/src/router/v1.ts b/apps/backend/src/router/v1.ts index 4ac069e8..1f96e357 100644 --- a/apps/backend/src/router/v1.ts +++ b/apps/backend/src/router/v1.ts @@ -1,9 +1,47 @@ import { Router } from 'express'; +import { db } from '../db'; const v1Router = Router(); +export const IN_PROGRESS = 'IN_PROGRESS'; + v1Router.get('/', (req, res) => { res.send('Hello, World!'); }); +v1Router.get('/games', async (req, res) => { + try { + const games = await db.game.findMany({ + include: { + blackPlayer: true, + whitePlayer: true, + }, + where: { + status: IN_PROGRESS, + }, + }); + res.json(games); + } catch (e: any) { + res.status(500).json({ error: e.message }); + } +}); + +v1Router.get('/games/:gameId', async (req, res) => { + try { + const game = await db.game.findUnique({ + include: { + blackPlayer: true, + whitePlayer: true, + moves: true, + }, + where: { + id: req.params.gameId, + }, + }); + res.json(game); + } catch (e: any) { + res.status(500).json({ error: e.message }); + } +}); + export default v1Router; diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index e0a1abfa..385cf2bd 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -7,6 +7,8 @@ import { Suspense } from 'react'; import { RecoilRoot } from 'recoil'; import { useUser } from '@repo/store/useUser'; import { Loader } from './components/Loader'; +import { Spectate } from './screens/Spectate'; +import { Review } from './screens/Review'; import { Layout } from './layout'; function App() { @@ -27,13 +29,15 @@ function AuthApp() { } />} /> + } /> + } />} /> } + path="/spectate/:gameId" + element={} />} /> } />} + path="/review/:gameId" + element={} />} /> diff --git a/apps/frontend/src/components/ChessBoard.tsx b/apps/frontend/src/components/ChessBoard.tsx index c45c4d6d..1b58ee87 100644 --- a/apps/frontend/src/components/ChessBoard.tsx +++ b/apps/frontend/src/components/ChessBoard.tsx @@ -10,6 +10,7 @@ import useWindowSize from '../hooks/useWindowSize'; import Confetti from 'react-confetti'; import MoveSound from '/move.wav'; import CaptureSound from '/capture.wav'; +import { useNavigate } from 'react-router-dom'; import { useRecoilState } from 'recoil'; @@ -44,358 +45,396 @@ export function isPromoting(chess: Chess, from: Square, to: Square) { .includes(to); } -export const ChessBoard = memo(({ - gameId, - started, - myColor, - chess, - board, - socket, - setBoard, -}: { - myColor: Color; - gameId: string; - started: boolean; - chess: Chess; - setBoard: React.Dispatch< - React.SetStateAction< - ({ - square: Square; - type: PieceSymbol; - color: Color; - } | null)[][] - > - >; - board: ({ - square: Square; - type: PieceSymbol; - color: Color; - } | null)[][]; - socket: WebSocket; -}) => { - console.log("chessboard reloaded") - const { height, width } = useWindowSize(); +export const ChessBoard = memo( + ({ + gameId, + started, + myColor, + chess, + board, + socket, + setBoard, + }: { + myColor: Color; + gameId: string; + started: boolean; + chess: Chess; + setBoard: React.Dispatch< + React.SetStateAction< + ({ + square: Square; + type: PieceSymbol; + color: Color; + } | null)[][] + > + >; + board: ({ + square: Square; + type: PieceSymbol; + color: Color; + } | null)[][]; + socket: WebSocket; + }) => { + console.log('chessboard reloaded'); + const { height, width } = useWindowSize(); - const [isFlipped, setIsFlipped] = useRecoilState(isBoardFlippedAtom); - const [userSelectedMoveIndex, setUserSelectedMoveIndex] = useRecoilState( - userSelectedMoveIndexAtom, - ); - const [moves, setMoves] = useRecoilState(movesAtom); - const [lastMove, setLastMove] = useState<{ from: string; to: string } | null>( - null, - ); - const [rightClickedSquares, setRightClickedSquares] = useState([]); - const [arrowStart, setArrowStart] = useState(null); + const [isFlipped, setIsFlipped] = useRecoilState(isBoardFlippedAtom); + const [userSelectedMoveIndex, setUserSelectedMoveIndex] = useRecoilState( + userSelectedMoveIndexAtom, + ); + const [moves, setMoves] = useRecoilState(movesAtom); + const [lastMove, setLastMove] = useState<{ + from: string; + to: string; + } | null>(null); + const [rightClickedSquares, setRightClickedSquares] = useState( + [], + ); + const [arrowStart, setArrowStart] = useState(null); - const [from, setFrom] = useState(null); - const isMyTurn = myColor === chess.turn(); - const [legalMoves, setLegalMoves] = useState([]); + const [from, setFrom] = useState(null); + const isMyTurn = myColor === chess.turn(); + const [legalMoves, setLegalMoves] = useState([]); - const labels = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; - const [canvas, setCanvas] = useState(null); - const OFFSET = 100; - const boxSize = - width > height - ? Math.floor((height - OFFSET) / 8) - : Math.floor((width - OFFSET) / 8); - const [gameOver, setGameOver] = useState(false); - const moveAudio = new Audio(MoveSound); - const captureAudio = new Audio(CaptureSound); + const labels = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; + const [canvas, setCanvas] = useState(null); + const OFFSET = 100; + const boxSize = + width > height + ? Math.floor((height - OFFSET) / 8) + : Math.floor((width - OFFSET) / 8); + const [gameOver, setGameOver] = useState(false); + const moveAudio = new Audio(MoveSound); + const captureAudio = new Audio(CaptureSound); - const handleMouseDown = ( - e: MouseEvent, - squareRep: string, - ) => { - e.preventDefault(); - if (e.button === 2) { - setArrowStart(squareRep); - } - }; + const Navigate = useNavigate(); - useEffect(() => { - if (myColor === 'b') { - setIsFlipped(true); - } - }, [myColor]); - - const clearCanvas = () => { - setRightClickedSquares([]); - if (canvas) { - const ctx = canvas.getContext('2d'); - ctx?.clearRect(0, 0, canvas.width, canvas.height); - } - }; + const handleMouseDown = ( + e: MouseEvent, + squareRep: string, + ) => { + e.preventDefault(); + if (e.button === 2) { + setArrowStart(squareRep); + } + }; - const handleRightClick = (squareRep: string) => { - if (rightClickedSquares.includes(squareRep)) { - setRightClickedSquares((prev) => prev.filter((sq) => sq !== squareRep)); - } else { - setRightClickedSquares((prev) => [...prev, squareRep]); - } - }; + useEffect(() => { + if (myColor === 'b') { + setIsFlipped(true); + } + }, [myColor]); - const handleDrawArrow = (squareRep: string) => { - if (arrowStart) { - const stoppedAtSquare = squareRep; + const clearCanvas = () => { + setRightClickedSquares([]); if (canvas) { const ctx = canvas.getContext('2d'); - if (ctx) { - drawArrow({ - ctx, - start: arrowStart, - end: stoppedAtSquare, - isFlipped, - squareSize: boxSize, - }); - } + ctx?.clearRect(0, 0, canvas.width, canvas.height); } - setArrowStart(null); - } - }; + }; - const handleMouseUp = (e: MouseEvent, squareRep: string) => { - e.preventDefault(); - if (!started) { - return; - } - if (e.button === 2) { - if (arrowStart === squareRep) { - handleRightClick(squareRep); + const handleRightClick = (squareRep: string) => { + if (rightClickedSquares.includes(squareRep)) { + setRightClickedSquares((prev) => prev.filter((sq) => sq !== squareRep)); } else { - handleDrawArrow(squareRep); + setRightClickedSquares((prev) => [...prev, squareRep]); } - } else { - clearCanvas(); - } - }; + }; - useEffect(() => { - clearCanvas(); - const lMove = moves.at(-1); - if (lMove) { - setLastMove({ - from: lMove.from, - to: lMove.to, - }); - } else { - setLastMove(null); - } - }, [moves]); + const handleDrawArrow = (squareRep: string) => { + if (arrowStart) { + const stoppedAtSquare = squareRep; + if (canvas) { + const ctx = canvas.getContext('2d'); + if (ctx) { + drawArrow({ + ctx, + start: arrowStart, + end: stoppedAtSquare, + isFlipped, + squareSize: boxSize, + }); + } + } + setArrowStart(null); + } + }; - useEffect(() => { - if (userSelectedMoveIndex !== null) { - const move = moves[userSelectedMoveIndex]; - setLastMove({ - from: move.from, - to: move.to, - }); - chess.load(move.after); - setBoard(chess.board()); - return; - } - }, [userSelectedMoveIndex]); + const handleMouseUp = ( + e: MouseEvent, + squareRep: string, + ) => { + e.preventDefault(); + if (!started) { + return; + } + if (e.button === 2) { + if (arrowStart === squareRep) { + handleRightClick(squareRep); + } else { + handleDrawArrow(squareRep); + } + } else { + clearCanvas(); + } + }; + + useEffect(() => { + clearCanvas(); + const lMove = moves.at(-1); + if (lMove) { + setLastMove({ + from: lMove.from, + to: lMove.to, + }); + } else { + setLastMove(null); + } + }, [moves]); - useEffect(() => { - if (userSelectedMoveIndex !== null) { - chess.reset(); - moves.forEach((move) => { - chess.move({ from: move.from, to: move.to }); - }); - setBoard(chess.board()); - setUserSelectedMoveIndex(null); - } else { - setBoard(chess.board()); - } - }, [moves]); + useEffect(() => { + if (userSelectedMoveIndex !== null) { + const move = moves[userSelectedMoveIndex]; + setLastMove({ + from: move.from, + to: move.to, + }); + chess.load(move.after); + setBoard(chess.board()); + return; + } + }, [userSelectedMoveIndex]); - return ( - <> - {gameOver && } -
-
- {(isFlipped ? board.slice().reverse() : board).map((row, i) => { - i = isFlipped ? i + 1 : 8 - i; - return ( -
- - {(isFlipped ? row.slice().reverse() : row).map((square, j) => { - j = isFlipped ? 7 - (j % 8) : j % 8; + useEffect(() => { + if (userSelectedMoveIndex !== null) { + chess.reset(); + moves.forEach((move) => { + chess.move({ from: move.from, to: move.to }); + }); + setBoard(chess.board()); + setUserSelectedMoveIndex(null); + } else { + setBoard(chess.board()); + } + }, [moves]); - const isMainBoxColor = (i + j) % 2 !== 0; - const isPiece: boolean = !!square; - const squareRepresentation = (String.fromCharCode(97 + j) + - '' + - i) as Square; - const isHighlightedSquare = - from === squareRepresentation || - squareRepresentation === lastMove?.from || - squareRepresentation === lastMove?.to; - const isRightClickedSquare = - rightClickedSquares.includes(squareRepresentation); + return ( + <> + {gameOver && } +
+
+ {gameOver && ( +
{ + Navigate('/review/' + gameId); + }} + > + Review the Match +
+ )} + {(isFlipped ? board.slice().reverse() : board).map((row, i) => { + i = isFlipped ? i + 1 : 8 - i; + return ( +
+ + {(isFlipped ? row.slice().reverse() : row).map( + (square, j) => { + j = isFlipped ? 7 - (j % 8) : j % 8; - const piece = square && square.type; - const isKingInCheckSquare = - piece === 'k' && - square?.color === chess.turn() && - chess.inCheck(); + const isMainBoxColor = (i + j) % 2 !== 0; + const isPiece: boolean = !!square; + const squareRepresentation = (String.fromCharCode( + 97 + j, + ) + + '' + + i) as Square; + const isHighlightedSquare = + from === squareRepresentation || + squareRepresentation === lastMove?.from || + squareRepresentation === lastMove?.to; + const isRightClickedSquare = + rightClickedSquares.includes(squareRepresentation); - return ( -
{ - if (!started) { - return; - } - if (userSelectedMoveIndex !== null) { - chess.reset(); - moves.forEach((move) => { - chess.move({ from: move.from, to: move.to }); - }); - setBoard(chess.board()); - setUserSelectedMoveIndex(null); - return; - } - if (!from && square?.color !== chess.turn()) return; - if (!isMyTurn) return; - if (from != squareRepresentation) { - setFrom(squareRepresentation); - if (isPiece) { - setLegalMoves( - chess - .moves({ - verbose: true, - square: square?.square, - }) - .map((move) => move.to), - ); - } - } else { - setFrom(null); - } - if (!isPiece) { - setLegalMoves([]); - } + const piece = square && square.type; + const isKingInCheckSquare = + piece === 'k' && + square?.color === chess.turn() && + chess.inCheck(); - if (!from) { - setFrom(squareRepresentation); - setLegalMoves( - chess - .moves({ - verbose: true, - square: square?.square, - }) - .map((move) => move.to), - ); - } else { - try { - let moveResult: Move; - if ( - isPromoting(chess, from, squareRepresentation) - ) { - moveResult = chess.move({ - from, - to: squareRepresentation, - promotion: 'q', - }); - } else { - moveResult = chess.move({ - from, - to: squareRepresentation, + return ( +
{ + if (!started) { + return; + } + if (userSelectedMoveIndex !== null) { + chess.reset(); + moves.forEach((move) => { + chess.move({ from: move.from, to: move.to }); }); + setBoard(chess.board()); + setUserSelectedMoveIndex(null); + return; } - if (moveResult) { - moveAudio.play(); - - if (moveResult?.captured) { - captureAudio.play(); + if (!from && square?.color !== chess.turn()) return; + if (!isMyTurn) return; + if (from != squareRepresentation) { + setFrom(squareRepresentation); + if (isPiece) { + setLegalMoves( + chess + .moves({ + verbose: true, + square: square?.square, + }) + .map((move) => move.to), + ); } - setMoves((prev) => [...prev, moveResult]); + } else { setFrom(null); + } + if (!isPiece) { setLegalMoves([]); - if (moveResult.san.includes('#')) { - setGameOver(true); - } - socket.send( - JSON.stringify({ - type: MOVE, - payload: { - gameId, - move: moveResult, - }, - }), + } + + if (!from) { + setFrom(squareRepresentation); + setLegalMoves( + chess + .moves({ + verbose: true, + square: square?.square, + }) + .map((move) => move.to), ); + } else { + try { + const time = new Date(Date.now()).getTime(); + let moveResult: Move; + if ( + isPromoting(chess, from, squareRepresentation) + ) { + moveResult = chess.move({ + from, + to: squareRepresentation, + promotion: 'q', + }); + } else { + moveResult = chess.move({ + from, + to: squareRepresentation, + }); + } + const piece = + chess.get(squareRepresentation)?.type; + if (moveResult) { + moveAudio.play(); + + if (moveResult?.captured) { + captureAudio.play(); + } + setFrom(null); + setLegalMoves([]); + if (moveResult.san.includes('#')) { + setGameOver(true); + } + } + socket.send( + JSON.stringify({ + type: MOVE, + payload: { + gameId, + move: { + from, + to: squareRepresentation, + san: moveResult?.san, + before: moveResult?.before, + after: moveResult?.after, + piece, + // createdAt: myMoveStartTime, + // timeTaken, + }, + }, + }), + ); + setFrom(null); + setLegalMoves([]); + setBoard(chess.board()); + } catch (e) { + console.log(e); + } } - } catch (e) { - console.log('e', e); - } - } - }} - style={{ - width: boxSize, - height: boxSize, - }} - key={j} - className={`${isRightClickedSquare ? (isMainBoxColor ? 'bg-[#CF664E]' : 'bg-[#E87764]') : isKingInCheckSquare ? 'bg-[#FF6347]' : isHighlightedSquare ? `${isMainBoxColor ? 'bg-[#BBCB45]' : 'bg-[#F4F687]'}` : isMainBoxColor ? 'bg-[#739552]' : 'bg-[#EBEDD0]'} ${''}`} - onContextMenu={(e) => { - e.preventDefault(); - }} - onMouseDown={(e) => { - handleMouseDown(e, squareRepresentation); - }} - onMouseUp={(e) => { - handleMouseUp(e, squareRepresentation); - }} - > -
- {square && } - {isFlipped - ? i === 8 && ( - - ) - : i === 1 && ( - - )} - {!!from && - legalMoves.includes(squareRepresentation) && ( - - )} -
-
- ); - })} -
- ); - })} -
+ }} + style={{ + width: boxSize, + height: boxSize, + }} + key={j} + className={`${isRightClickedSquare ? (isMainBoxColor ? 'bg-[#CF664E]' : 'bg-[#E87764]') : isKingInCheckSquare ? 'bg-[#FF6347]' : isHighlightedSquare ? `${isMainBoxColor ? 'bg-[#BBCB45]' : 'bg-[#F4F687]'}` : isMainBoxColor ? 'bg-[#739552]' : 'bg-[#EBEDD0]'} ${''}`} + onContextMenu={(e) => { + e.preventDefault(); + }} + onMouseDown={(e) => { + handleMouseDown(e, squareRepresentation); + }} + onMouseUp={(e) => { + handleMouseUp(e, squareRepresentation); + }} + > +
+ {square && } + {isFlipped + ? i === 8 && ( + + ) + : i === 1 && ( + + )} + {!!from && + legalMoves.includes(squareRepresentation) && ( + + )} +
+
+ ); + }, + )} +
+ ); + })} +
- setCanvas(ref)} - width={boxSize * 8} - height={boxSize * 8} - style={{ - position: 'absolute', - top: 0, - left: 0, - pointerEvents: 'none', - }} - onContextMenu={(e) => e.preventDefault()} - onMouseDown={(e) => { - e.preventDefault(); - }} - onMouseUp={(e) => e.preventDefault()} - > -
- - ); -}); + setCanvas(ref)} + width={boxSize * 8} + height={boxSize * 8} + style={{ + position: 'absolute', + top: 0, + left: 0, + pointerEvents: 'none', + }} + onContextMenu={(e) => e.preventDefault()} + onMouseDown={(e) => { + e.preventDefault(); + }} + onMouseUp={(e) => e.preventDefault()} + > +
+ + ); + }, +); diff --git a/apps/frontend/src/components/GameEndModal.tsx b/apps/frontend/src/components/GameEndModal.tsx index 428caade..bab0167d 100644 --- a/apps/frontend/src/components/GameEndModal.tsx +++ b/apps/frontend/src/components/GameEndModal.tsx @@ -2,19 +2,23 @@ import React, { useState } from 'react'; import WhiteKing from '../../public/wk.png'; import BlackKing from '../../public/bk.png'; import { GameResult, Result } from '@/screens/Game'; +import { useNavigate } from 'react-router-dom'; interface ModalProps { blackPlayer?: { id: string; name: string }; whitePlayer?: { id: string; name: string }; gameResult: GameResult; + gameId: string; } const GameEndModal: React.FC = ({ blackPlayer, whitePlayer, gameResult, + gameId, }) => { const [isOpen, setIsOpen] = useState(true); + const Navigate = useNavigate(); const closeModal = () => { setIsOpen(false); @@ -53,6 +57,12 @@ const GameEndModal: React.FC = ({ ); }; + const reviewRedirect = () => { + Navigate(`/review/${gameId}`, { + replace: false, + }); + }; + const getWinnerMessage = (result: Result) => { switch (result) { case Result.BLACK_WINS: @@ -76,20 +86,38 @@ const GameEndModal: React.FC = ({
-

- {getWinnerMessage(gameResult.result)} +

+ {getWinnerMessage(gameResult.result)}

-

by {gameResult.by}

+

+ by {gameResult.by} +

- +
vs
- +
-
+
+ @@ -327,9 +367,46 @@ export const Game = () => { )}
)} -
- -
+ {started && ( +
+ +
+
+

+ Chat Messages +

+
+ {messages.map((it, i) => ( +
+
+

{it.value}

+
+
+ ))} +
+
+
+ { + setMessageState(e.target.value); + }} + className="flex-1 border border-gray-300 rounded-md px-3 py-1 mr-2 focus:outline-none focus:border-blue-400" + /> + +
+
+
+ )}
diff --git a/apps/frontend/src/screens/Landing.tsx b/apps/frontend/src/screens/Landing.tsx index 6fec6f08..69a5e622 100644 --- a/apps/frontend/src/screens/Landing.tsx +++ b/apps/frontend/src/screens/Landing.tsx @@ -1,6 +1,44 @@ import { PlayCard } from '@/components/Card'; +import { Button } from '@/components/ui/button'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface Game { + id: string; + whitePlayer: { name: string }; + blackPlayer: { name: string }; + result: string; +} export const Landing = () => { + const [games, setGames] = useState([]); + const navigate = useNavigate(); + + useEffect(() => { + const onLoad = async () => { + try { + const res = await fetch( + `${import.meta.env.VITE_APP_BACKEND_URL}/v1/games`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + if (res.ok) { + const data = await res.json(); + setGames(data); + } + } catch (e) { + console.log(e); + setGames([]); + } + }; + + onLoad(); + }, []); + return (
@@ -11,6 +49,40 @@ export const Landing = () => { />
+
+ {games.length > 0 && ( +
+

+ Live Games +

+
+ {games.map((game: Game) => ( +
+

+ {game.whitePlayer.name} vs {game.blackPlayer.name} +

+

+ {game.result ? 'Game Over' : 'In Progress'} +

+ +
+ ))} +
+
+ )} +
); }; diff --git a/apps/frontend/src/screens/Review.tsx b/apps/frontend/src/screens/Review.tsx new file mode 100644 index 00000000..0c3d2079 --- /dev/null +++ b/apps/frontend/src/screens/Review.tsx @@ -0,0 +1,160 @@ +/* eslint-disable no-case-declarations */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useEffect, useState } from 'react'; +import MoveSound from '../../public/move.wav'; +import { Square } from 'chess.js'; +import { useParams } from 'react-router-dom'; +import MovesTable from '../components/MovesTable'; +import { UserAvatar } from '../components/UserAvatar'; +import { ReviewChessBoard } from '../components/ReviewChessBoard'; +import { TimingMove } from '@repo/store/chessBoard'; + +// TODO: Move together, there's code repetition here +export const INIT_GAME = 'init_game'; +export const MOVE = 'move'; +export const OPPONENT_DISCONNECTED = 'opponent_disconnected'; +export const GAME_OVER = 'game_over'; +export const JOIN_ROOM = 'join_room'; +export const GAME_JOINED = 'game_joined'; +export const GAME_ALERT = 'game_alert'; +export const GAME_ADDED = 'game_added'; +export const USER_TIMEOUT = 'user_timeout'; + +const GAME_TIME_MS = 10 * 60 * 1000; + +export interface IMove { + from: Square; + to: Square; + piece: string; +} + +const moveAudio = new Audio(MoveSound); + +interface Player { + id: string; + name: string; +} + +export const Review = () => { + const { gameId } = useParams(); + + // Todo move to store/context + const [whitePlayer, setWhitePlayer] = useState(null); + const [blackPlayer, setBlackPlayer] = useState(null); + const [moves, setMoves] = useState([]); + const [activeMove, setActiveMove] = useState(0); + + useEffect(() => { + const onLoad = async () => { + try { + const res = await fetch( + `${import.meta.env.VITE_APP_BACKEND_URL}/v1/games/${gameId}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + if (res.ok) { + const data = await res.json(); + // setGames(data); + setWhitePlayer({ + id: data.whitePlayer.id, + name: data.whitePlayer.name, + }); + setBlackPlayer({ + id: data.blackPlayer.id, + name: data.blackPlayer.name, + }); + data.moves.forEach((move: TimingMove) => { + setMoves((prevMoves) => [...prevMoves, move]); + }); + } + } catch (e) { + console.log(e); + } + }; + + onLoad(); + }, [gameId]); + + return ( +
+ {/* {result && ( +
+ {result === 'WHITE_WINS' && 'White wins'} + {result === 'BLACK_WINS' && 'Black wins'} + {result === 'DRAW' && 'Draw'} +
+ )} + {started && ( +
+ {moves.length % 2 === 0 ? 'White to move' : 'Black to move'} +
+ )} */} +
+
+
+
+
+
+
+ { +
+ +
+ } +
+
+
+ +
+
+ { +
+ +
+ { + if (activeMove > 0) { + setActiveMove(activeMove - 1); + } + }} + > + {'<'} + + { + if (activeMove < moves.length) { + setActiveMove(activeMove + 1); + } + }} + > + {'>'} + +
+
+ } +
+
+
+
+
+ {moves.slice(0, activeMove).length > 0 && ( +
+ +
+ )} +
+
+
+
+
+
+ ); +}; diff --git a/apps/frontend/src/screens/Spectate.tsx b/apps/frontend/src/screens/Spectate.tsx new file mode 100644 index 00000000..ac5bb9ba --- /dev/null +++ b/apps/frontend/src/screens/Spectate.tsx @@ -0,0 +1,314 @@ +/* eslint-disable no-case-declarations */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useEffect, useState } from 'react'; +import MoveSound from '../../public/move.wav'; + +import { ChessBoard, isPromoting } from '../components/ChessBoard'; +import { useSocket } from '../hooks/useSocket'; +import { Chess, Move, Square } from 'chess.js'; +import { useNavigate, useParams } from 'react-router-dom'; +import MovesTable from '../components/MovesTable'; +import { UserAvatar } from '../components/UserAvatar'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { movesAtom } from '@repo/store/chessBoard'; + +// TODO: Move together, there's code repetition here +export const INIT_GAME = 'init_game'; +export const MOVE = 'move'; +export const OPPONENT_DISCONNECTED = 'opponent_disconnected'; +export const GAME_OVER = 'game_over'; +export const JOIN_ROOM = 'join_room'; +export const GAME_JOINED = 'game_joined'; +export const GAME_ALERT = 'game_alert'; +export const GAME_ADDED = 'game_added'; +export const GAME_MESSAGE = 'game_message'; +export const USER_TIMEOUT = 'user_timeout'; + +const GAME_TIME_MS = 10 * 60 * 1000; + +export interface IMove { + from: Square; + to: Square; + piece: string; + startTime: number; + endTime: number; +} + +export interface Message { + value: string; + userId: string; + name: string; +} + +const moveAudio = new Audio(MoveSound); + +interface Metadata { + blackPlayer: { id: string; name: string }; + whitePlayer: { id: string; name: string }; +} + +export const Spectate = () => { + const setMoves = useSetRecoilState(movesAtom); + const moves = useRecoilState(movesAtom); + const socket = useSocket(); + const { gameId } = useParams(); + + const navigate = useNavigate(); + // Todo move to store/context + const [messages, setMessages] = useState([]); + const [chess, _setChess] = useState(new Chess()); + const [board, setBoard] = useState(chess.board()); + const [added, setAdded] = useState(false); + const [started, setStarted] = useState(false); + const [gameMetadata, setGameMetadata] = useState(null); + const [result, setResult] = useState< + | 'WHITE_WINS' + | 'BLACK_WINS' + | 'DRAW' + | typeof OPPONENT_DISCONNECTED + | typeof USER_TIMEOUT + | null + >(null); + const [player1TimeConsumed, setPlayer1TimeConsumed] = useState(0); + const [player2TimeConsumed, setPlayer2TimeConsumed] = useState(0); + + useEffect(() => { + if (!socket) { + return; + } + socket.onmessage = (event) => { + const message = JSON.parse(event.data); + + switch (message.type) { + case GAME_ADDED: + setAdded(true); + break; + case INIT_GAME: + setBoard(chess.board()); + setStarted(true); + navigate(`/game/${message.payload.gameId}`); + setGameMetadata({ + blackPlayer: message.payload.blackPlayer, + whitePlayer: message.payload.whitePlayer, + }); + break; + case MOVE: + const { move, player1TimeConsumed, player2TimeConsumed } = + message.payload; + setPlayer1TimeConsumed(player1TimeConsumed); + setPlayer2TimeConsumed(player2TimeConsumed); + const moves = chess.moves({ verbose: true }); + if ( + moves.map((x) => JSON.stringify(x)).includes(JSON.stringify(move)) + ) { + return; + } + if (isPromoting(chess, move.from, move.to)) { + chess.move({ + from: move.from, + to: move.to, + promotion: 'q', + }); + } else { + chess.move({ from: move.from, to: move.to }); + } + moveAudio.play(); + setBoard(chess.board()); + const piece = chess.get(move.to)?.type; + setMoves((moves) => [ + ...moves, + { + ...move, + piece, + }, + ]); + // if (move.player2UserId === user.id) { + // setMyTimer(move.player2Time); + // setOppotentTimer(move.player1Time); + // } else { + // setMyTimer(move.player1Time); + // setOppotentTimer(move.player2Time); + // } + break; + case GAME_OVER: + setResult(message.payload.result); + break; + + case OPPONENT_DISCONNECTED: + setResult(OPPONENT_DISCONNECTED); + break; + + case USER_TIMEOUT: + setResult(message.payload.win); + break; + + case GAME_JOINED: + setGameMetadata({ + blackPlayer: message.payload.blackPlayer, + whitePlayer: message.payload.whitePlayer, + }); + setPlayer1TimeConsumed(message.payload.player1TimeConsumed); + setPlayer2TimeConsumed(message.payload.player2TimeConsumed); + setStarted(true); + setMoves(message.payload.moves); + message.payload.moves.map((x: Move) => { + if (isPromoting(chess, x.from, x.to)) { + chess.move({ ...x, promotion: 'q' }); + } else { + chess.move(x); + } + }); + setBoard(chess.board()); + break; + + case GAME_MESSAGE: + setMessages((messages) => [ + ...messages, + { + value: message.payload.message, + userId: message.payload.user.id, + name: message.payload.user.name, + }, + ]); + break; + + default: + break; + } + }; + + if (gameId !== 'random') { + socket.send( + JSON.stringify({ + type: JOIN_ROOM, + payload: { + gameId, + }, + }), + ); + } + }, [chess, socket]); + + useEffect(() => { + if (started) { + const interval = setInterval(() => { + if (chess.turn() === 'w') { + setPlayer1TimeConsumed((p) => p + 100); + } else { + setPlayer2TimeConsumed((p) => p + 100); + } + }, 100); + return () => clearInterval(interval); + } + }, [started, gameMetadata]); + + const getTimer = (tempTime: number) => { + const minutes = Math.floor(tempTime / (1000 * 60)); + const remainingSeconds = Math.floor((tempTime % (1000 * 60)) / 1000); + + return ( +
+ Time Left: {minutes < 10 ? '0' : ''} + {minutes}:{remainingSeconds < 10 ? '0' : ''} + {remainingSeconds} +
+ ); + }; + + if (!socket) return
Connecting...
; + + return ( +
+ {result && ( +
+ {result === 'WHITE_WINS' && 'White wins'} + {result === 'BLACK_WINS' && 'Black wins'} + {result === 'DRAW' && 'Draw'} +
+ )} + {started && ( +
+ {moves.length % 2 === 0 ? 'White to move' : 'Black to move'} +
+ )} +
+
+
+
+
+
+
+ {started && ( +
+ + {getTimer(player2TimeConsumed)} +
+ )} +
+
+
+ +
+
+ {started && ( +
+ + {getTimer(player1TimeConsumed)} +
+ )} +
+
+
+
+
+ {moves.length > 0 ? ( +
+ +
+
+

+ Chat Messages +

+
+ {messages.map((it, i) => ( +
+
+ +
+
+

{it.value}

+
+
+ ))} +
+
+
+
+ ) : null} +
+
+
+
+
+
+ ); +}; diff --git a/apps/frontend/src/utils/canvas.ts b/apps/frontend/src/utils/canvas.ts index 2aeddfd2..3387e82b 100644 --- a/apps/frontend/src/utils/canvas.ts +++ b/apps/frontend/src/utils/canvas.ts @@ -1,6 +1,4 @@ - const calculateX = (square: string, isFlipped: boolean, squareSize: number) => { - let columnIndex = square.charCodeAt(0) - 'a'.charCodeAt(0); // Convert column letter to index if (isFlipped) { columnIndex = 7 - columnIndex; // Reverse the column index if the board is flipped @@ -10,21 +8,25 @@ const calculateX = (square: string, isFlipped: boolean, squareSize: number) => { }; const calculateY = (square: string, isFlipped: boolean, squareSize: number) => { - let rowIndex = 8 - parseInt(square[1]); // Convert row number to index (assuming rank 1 is at the bottom) if (isFlipped) { rowIndex = 7 - rowIndex; // Reverse the row index if the board is flipped } return rowIndex * squareSize + squareSize / 2; }; -export const drawArrow = ( - { - ctx, start, end, isFlipped, squareSize - }:{ ctx: CanvasRenderingContext2D, - start: string, - end: string, - isFlipped: boolean,squareSize: number} -) => { +export const drawArrow = ({ + ctx, + start, + end, + isFlipped, + squareSize, +}: { + ctx: CanvasRenderingContext2D; + start: string; + end: string; + isFlipped: boolean; + squareSize: number; +}) => { const startX = calculateX(start, isFlipped, squareSize); const startY = calculateY(start, isFlipped, squareSize); const endX = calculateX(end, isFlipped, squareSize); diff --git a/apps/ws/src/Game.ts b/apps/ws/src/Game.ts index e5c632dc..8b353d78 100644 --- a/apps/ws/src/Game.ts +++ b/apps/ws/src/Game.ts @@ -1,18 +1,19 @@ -import { Chess, Move, Square } from 'chess.js'; -import { - GAME_ENDED, - INIT_GAME, - MOVE, -} from './messages'; +import { Chess, PieceSymbol, Move, Square } from 'chess.js'; +import { GAME_ENDED, GAME_MESSAGE, INIT_GAME, MOVE } from './messages'; import { db } from './db'; import { randomUUID } from 'crypto'; import { SocketManager, User } from './SocketManager'; type GAME_STATUS = 'IN_PROGRESS' | 'COMPLETED' | 'ABANDONED' | 'TIME_UP'; -type GAME_RESULT = "WHITE_WINS" | "BLACK_WINS" | "DRAW"; +type GAME_RESULT = 'WHITE_WINS' | 'BLACK_WINS' | 'DRAW'; const GAME_TIME_MS = 10 * 60 * 60 * 1000; +interface TimingMove extends Move { + createdAt: Date; + timeTaken: number; +} + export function isPromoting(chess: Chess, from: Square, to: Square) { if (!from) { return false; @@ -49,10 +50,17 @@ export class Game { public result: GAME_RESULT | null = null; private player1TimeConsumed = 0; private player2TimeConsumed = 0; + private player1MoveStartTime = new Date(Date.now()); + private player2MoveStartTime = new Date(Date.now()); private startTime = new Date(Date.now()); private lastMoveTime = new Date(Date.now()); - constructor(player1UserId: string, player2UserId: string | null, gameId?: string, startTime?: Date) { + constructor( + player1UserId: string, + player2UserId: string | null, + gameId?: string, + startTime?: Date, + ) { this.player1UserId = player1UserId; this.player2UserId = player2UserId; this.board = new Chess(); @@ -63,21 +71,24 @@ export class Game { } } - seedMoves(moves: { - id: string; - gameId: string; - moveNumber: number; - from: string; - to: string; - comments: string | null; - timeTaken: number | null; - createdAt: Date; - }[]) { - console.log(moves); + seedMoves( + moves: { + id: string; + gameId: string; + moveNumber: number; + from: string; + to: string; + piece: string | null; + comments: string | null; + before: string; + after: string; + timeTaken: number | null; + createdAt: Date; + san: string | null; + }[], + ) { moves.forEach((move) => { - if ( - isPromoting(this.board, move.from as Square, move.to as Square) - ) { + if (isPromoting(this.board, move.from as Square, move.to as Square)) { this.board.move({ from: move.from, to: move.to, @@ -109,6 +120,7 @@ export class Game { } async updateSecondPlayer(player2UserId: string) { this.player2UserId = player2UserId; + this.player1MoveStartTime = new Date(Date.now()); const users = await db.user.findMany({ where: { @@ -125,24 +137,32 @@ export class Game { return; } + const initGameBroadcastMessage = JSON.stringify({ + type: INIT_GAME, + payload: { + gameId: this.gameId, + whitePlayer: { + name: users.find((user) => user.id === this.player1UserId)?.name, + id: this.player1UserId, + }, + blackPlayer: { + name: users.find((user) => user.id === this.player2UserId)?.name, + id: this.player2UserId, + }, + fen: this.board.fen(), + startTime: new Date(Date.now()), + moves: [], + }, + }); + SocketManager.getInstance().broadcast( this.gameId, - JSON.stringify({ - type: INIT_GAME, - payload: { - gameId: this.gameId, - whitePlayer: { - name: users.find((user) => user.id === this.player1UserId)?.name, - id: this.player1UserId, - }, - blackPlayer: { - name: users.find((user) => user.id === this.player2UserId)?.name, - id: this.player2UserId, - }, - fen: this.board.fen(), - moves: [], - }, - }), + initGameBroadcastMessage, + ); + + SocketManager.getInstance().broadcastToSpectators( + this.gameId, + initGameBroadcastMessage, ); } @@ -176,8 +196,8 @@ export class Game { this.gameId = game.id; } - async addMoveToDb(move: Move, moveTimestamp: Date) { - + async addMoveToDb(move: TimingMove) { + console.log(move); await db.$transaction([ db.move.create({ data: { @@ -185,11 +205,11 @@ export class Game { moveNumber: this.moveCount + 1, from: move.from, to: move.to, - before: move.before, - after: move.after, - createdAt: moveTimestamp, - timeTaken: moveTimestamp.getTime() - this.lastMoveTime.getTime(), - san: move.san + before: move.before ?? '', + after: move.after ?? '', + createdAt: move.createdAt, + timeTaken: move.timeTaken, + san: move.san, }, }), db.game.update({ @@ -203,11 +223,7 @@ export class Game { ]); } - async makeMove( - user: User, - move: Move - ) { - + async makeMove(user: User, move: Move) { // validate the type of move using zod if (this.board.turn() === 'w' && user.userId !== this.player1UserId) { return; @@ -218,7 +234,9 @@ export class Game { } if (this.result) { - console.error(`User ${user.userId} is making a move post game completion`); + console.error( + `User ${user.userId} is making a move post game completion`, + ); return; } @@ -238,56 +256,114 @@ export class Game { }); } } catch (e) { - console.error("Error while making move"); + console.error('Error while making move'); return; } // flipped because move has already happened if (this.board.turn() === 'b') { - this.player1TimeConsumed = this.player1TimeConsumed + (moveTimestamp.getTime() - this.lastMoveTime.getTime()); + this.player1TimeConsumed = + this.player1TimeConsumed + + (moveTimestamp.getTime() - this.lastMoveTime.getTime()); } if (this.board.turn() === 'w') { - this.player2TimeConsumed = this.player2TimeConsumed + (moveTimestamp.getTime() - this.lastMoveTime.getTime()); + this.player2TimeConsumed = + this.player2TimeConsumed + + (moveTimestamp.getTime() - this.lastMoveTime.getTime()); } - await this.addMoveToDb(move, moveTimestamp); - this.resetAbandonTimer() + const timeTaken = + user.userId === this.player1UserId + ? moveTimestamp.getTime() - this.player1MoveStartTime.getTime() + : moveTimestamp.getTime() - this.player2MoveStartTime.getTime(); + + await this.addMoveToDb({ + ...move, + createdAt: moveTimestamp, + timeTaken, + }); + this.resetAbandonTimer(); this.resetMoveTimer(); this.lastMoveTime = moveTimestamp; - - SocketManager.getInstance().broadcast( + if (user.userId === this.player1UserId) { + this.player2MoveStartTime = moveTimestamp; + } else { + this.player1MoveStartTime = moveTimestamp; + } + const moveBroadcastMessage = JSON.stringify({ + type: MOVE, + payload: { + move: { + ...move, + createAt: moveTimestamp, + timeTaken, + }, + userId: user.userId, + player1TimeConsumed: this.player1TimeConsumed, + player2TimeConsumed: this.player2TimeConsumed, + }, + }); + SocketManager.getInstance().broadcast(this.gameId, moveBroadcastMessage); + SocketManager.getInstance().broadcastToSpectators( this.gameId, - JSON.stringify({ - type: MOVE, - payload: { move, player1TimeConsumed: this.player1TimeConsumed, player2TimeConsumed: this.player2TimeConsumed }, - }), + moveBroadcastMessage, ); if (this.board.isGameOver()) { const result = this.board.isDraw() - ? 'DRAW' - : this.board.turn() === 'b' - ? 'WHITE_WINS' - : 'BLACK_WINS'; - - this.endGame("COMPLETED", result); + ? 'DRAW' + : this.board.turn() === 'b' + ? 'WHITE_WINS' + : 'BLACK_WINS'; + + this.endGame('COMPLETED', result); } this.moveCount++; } + async sendMessage(message: string, userId: string) { + const user = await db.user.findUnique({ + where: { + id: userId, + }, + }); + const messageBroadcast = JSON.stringify({ + type: GAME_MESSAGE, + payload: { + message, + user: { + id: userId, + name: user?.name, + }, + }, + }); + + SocketManager.getInstance().broadcast(this.gameId, messageBroadcast); + SocketManager.getInstance().broadcastToSpectators( + this.gameId, + messageBroadcast, + ); + } + getPlayer1TimeConsumed() { if (this.board.turn() === 'w') { - return this.player1TimeConsumed + (new Date(Date.now()).getTime() - this.lastMoveTime.getTime()); + return ( + this.player1TimeConsumed + + (new Date(Date.now()).getTime() - this.lastMoveTime.getTime()) + ); } return this.player1TimeConsumed; } getPlayer2TimeConsumed() { if (this.board.turn() === 'b') { - return this.player2TimeConsumed + (new Date(Date.now()).getTime() - this.lastMoveTime.getTime()); + return ( + this.player2TimeConsumed + + (new Date(Date.now()).getTime() - this.lastMoveTime.getTime()) + ); } return this.player2TimeConsumed; } @@ -297,19 +373,24 @@ export class Game { clearTimeout(this.timer); } this.timer = setTimeout(() => { - this.endGame("ABANDONED", this.board.turn() === 'b' ? 'WHITE_WINS' : 'BLACK_WINS'); + this.endGame( + 'ABANDONED', + this.board.turn() === 'b' ? 'WHITE_WINS' : 'BLACK_WINS', + ); }, 60 * 1000); } async resetMoveTimer() { if (this.moveTimer) { - clearTimeout(this.moveTimer) + clearTimeout(this.moveTimer); } const turn = this.board.turn(); - const timeLeft = GAME_TIME_MS - (turn === 'w' ? this.player1TimeConsumed : this.player2TimeConsumed); + const timeLeft = + GAME_TIME_MS - + (turn === 'w' ? this.player1TimeConsumed : this.player2TimeConsumed); this.moveTimer = setTimeout(() => { - this.endGame("TIME_UP", turn === 'b' ? 'WHITE_WINS' : 'BLACK_WINS'); + this.endGame('TIME_UP', turn === 'b' ? 'WHITE_WINS' : 'BLACK_WINS'); }, timeLeft); } @@ -330,7 +411,7 @@ export class Game { }, blackPlayer: true, whitePlayer: true, - } + }, }); SocketManager.getInstance().broadcast( @@ -358,7 +439,7 @@ export class Game { } clearMoveTimer() { - if(this.moveTimer) clearTimeout(this.moveTimer); + if (this.moveTimer) clearTimeout(this.moveTimer); } setTimer(timer: NodeJS.Timeout) { diff --git a/apps/ws/src/GameManager.ts b/apps/ws/src/GameManager.ts index 02703681..1d17776a 100644 --- a/apps/ws/src/GameManager.ts +++ b/apps/ws/src/GameManager.ts @@ -10,6 +10,8 @@ import { GAME_NOT_FOUND, GAME_ALERT, GAME_ADDED, + SPECTATE_MESSAGE, + GAME_MESSAGE, GAME_ENDED, } from './messages'; import { Game, isPromoting } from './Game'; @@ -90,9 +92,10 @@ export class GameManager { if (message.type === MOVE) { const gameId = message.payload.gameId; const game = this.games.find((game) => game.gameId === gameId); + console.log(message.payload.move); if (game) { game.makeMove(user, message.payload.move); - if (game.result) { + if (game.result) { this.removeGame(game.gameId); } } @@ -127,23 +130,25 @@ export class GameManager { return; } - if(gameFromDb.status !== GameStatus.IN_PROGRESS) { - user.socket.send(JSON.stringify({ - type: GAME_ENDED, - payload: { - result: gameFromDb.result, - status: gameFromDb.status, - moves: gameFromDb.moves, - blackPlayer: { - id: gameFromDb.blackPlayer.id, - name: gameFromDb.blackPlayer.name, - }, - whitePlayer: { - id: gameFromDb.whitePlayer.id, - name: gameFromDb.whitePlayer.name, + if (gameFromDb.status !== GameStatus.IN_PROGRESS) { + user.socket.send( + JSON.stringify({ + type: GAME_ENDED, + payload: { + result: gameFromDb.result, + status: gameFromDb.status, + moves: gameFromDb.moves, + blackPlayer: { + id: gameFromDb.blackPlayer.id, + name: gameFromDb.blackPlayer.name, + }, + whitePlayer: { + id: gameFromDb.whitePlayer.id, + name: gameFromDb.whitePlayer.name, + }, }, - } - })); + }), + ); return; } @@ -152,16 +157,13 @@ export class GameManager { gameFromDb?.whitePlayerId!, gameFromDb?.blackPlayerId!, gameFromDb.id, - gameFromDb.startAt + gameFromDb.startAt, ); - game.seedMoves(gameFromDb?.moves || []) + game.seedMoves(gameFromDb?.moves || []); this.games.push(game); availableGame = game; } - console.log(availableGame.getPlayer1TimeConsumed()); - console.log(availableGame.getPlayer2TimeConsumed()); - user.socket.send( JSON.stringify({ type: GAME_JOINED, @@ -184,6 +186,14 @@ export class GameManager { SocketManager.getInstance().addUser(user, gameId); } + + if (message.type === GAME_MESSAGE) { + const gameId = message.payload.gameId; + const game = this.games.find((game) => game.gameId === gameId); + if (game) { + game.sendMessage(message.payload.message, user.userId); + } + } }); } } diff --git a/apps/ws/src/SocketManager.ts b/apps/ws/src/SocketManager.ts index 42b6f3a4..92aaa650 100644 --- a/apps/ws/src/SocketManager.ts +++ b/apps/ws/src/SocketManager.ts @@ -17,10 +17,12 @@ export class SocketManager { private static instance: SocketManager; private interestedSockets: Map; private userRoomMappping: Map; + private spectatorSockets: Map; private constructor() { this.interestedSockets = new Map(); this.userRoomMappping = new Map(); + this.spectatorSockets = new Map(); } static getInstance() { @@ -40,6 +42,14 @@ export class SocketManager { this.userRoomMappping.set(user.userId, roomId); } + addSpectator(user: User, roomId: string) { + this.spectatorSockets.set(roomId, [ + ...(this.spectatorSockets.get(roomId) || []), + user, + ]); + this.userRoomMappping.set(user.id, roomId); + } + broadcast(roomId: string, message: string) { const users = this.interestedSockets.get(roomId); if (!users) { @@ -52,23 +62,47 @@ export class SocketManager { }); } + broadcastToSpectators(roomId: string, message: string) { + const users = this.spectatorSockets.get(roomId); + if (!users) { + console.error('No users in room?'); + return; + } + + users.forEach((user) => { + user.socket.send(message); + }); + } + removeUser(user: User) { const roomId = this.userRoomMappping.get(user.userId); if (!roomId) { console.error('User was not interested in any room?'); return; } - const room = this.interestedSockets.get(roomId) || [] - const remainingUsers = room.filter(u => - u.userId !== user.userId - ) - this.interestedSockets.set( - roomId, - remainingUsers - ); + const room = this.interestedSockets.get(roomId) || []; + const remainingUsers = room.filter((u) => u.userId !== user.userId); + this.interestedSockets.set(roomId, remainingUsers); if (this.interestedSockets.get(roomId)?.length === 0) { this.interestedSockets.delete(roomId); } this.userRoomMappping.delete(user.userId); } + + removeSpectator(user: User) { + const roomId = this.userRoomMappping.get(user.id); + if (!roomId) { + console.error('User was not interested in any room?'); + return; + } + this.spectatorSockets.set( + roomId, + (this.spectatorSockets.get(roomId) || []).filter((u) => u !== user), + ); + if (this.spectatorSockets.get(roomId)?.length === 0) { + this.spectatorSockets.delete(roomId); + } + + this.userRoomMappping.delete(user.id); + } } diff --git a/apps/ws/src/messages.ts b/apps/ws/src/messages.ts index 54e5bde3..73f95fb6 100644 --- a/apps/ws/src/messages.ts +++ b/apps/ws/src/messages.ts @@ -10,3 +10,5 @@ export const GAME_ENDED = 'game_ended'; export const GAME_ALERT = 'game_alert'; export const GAME_ADDED = 'game_added'; export const GAME_TIME = 'game_time'; +export const GAME_MESSAGE = 'game_message'; +export const SPECTATE_MESSAGE = 'spectate_message'; diff --git a/packages/db/prisma/migrations/20240422160656_added_piece_in_move/migration.sql b/packages/db/prisma/migrations/20240422160656_added_piece_in_move/migration.sql new file mode 100644 index 00000000..1b6599dd --- /dev/null +++ b/packages/db/prisma/migrations/20240422160656_added_piece_in_move/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Move" ADD COLUMN "piece" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8faaf75c..557a99fa 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -50,6 +50,7 @@ model Move { moveNumber Int from String to String + piece String? comments String? before String after String diff --git a/packages/store/src/atoms/chessBoard.ts b/packages/store/src/atoms/chessBoard.ts index 4a634052..844fb417 100644 --- a/packages/store/src/atoms/chessBoard.ts +++ b/packages/store/src/atoms/chessBoard.ts @@ -1,17 +1,22 @@ import { Move } from 'chess.js'; -import { atom } from "recoil"; +import { atom } from 'recoil'; + +export interface TimingMove extends Move { + createdAt: Date; + timeTaken: number; +} export const isBoardFlippedAtom = atom({ - key: "isBoardFlippedAtom", - default: false, -}) + key: 'isBoardFlippedAtom', + default: false, +}); -export const movesAtom = atom({ - key: "movesAtom", - default: [] -}) +export const movesAtom = atom({ + key: 'movesAtom', + default: [], +}); export const userSelectedMoveIndexAtom = atom({ - key: 'userSelectedMoveIndex', - default: null -}); \ No newline at end of file + key: 'userSelectedMoveIndex', + default: null, +});