From 814d3ecf09dd6016899bac3f56fdaf9edabce526 Mon Sep 17 00:00:00 2001 From: GuillaumeSD Date: Fri, 23 Feb 2024 04:01:18 +0100 Subject: [PATCH] feat : add board interactions --- src/hooks/useChess.ts | 12 ++- src/hooks/useCurrentMove.ts | 31 +++++++ src/hooks/useGameDatabase.ts | 4 +- src/lib/engine/stockfish.ts | 62 +++++++++++--- src/pages/database.tsx | 32 +++++++- src/sections/analysis/analyzeButton.tsx | 30 +++++-- src/sections/analysis/board.tsx | 80 +++++++++++++++++-- src/sections/analysis/loadGame.tsx | 45 ++++++++--- src/sections/analysis/reviewPanelBody.tsx | 31 +++++-- src/sections/analysis/reviewPanelToolbar.tsx | 51 ------------ .../reviewPanelToolbar/flipBoardButton.tsx | 14 ++++ .../goToLastPositionButton.tsx | 28 +++++++ .../analysis/reviewPanelToolbar/index.tsx | 46 +++++++++++ .../reviewPanelToolbar/nextMoveButton.tsx | 42 ++++++++++ .../reviewPanelToolbar/saveButton.tsx | 36 +++++++++ src/sections/analysis/states.ts | 1 + src/sections/layout/NavBar.tsx | 6 +- src/types/enums.ts | 4 + src/types/eval.ts | 10 +++ 19 files changed, 458 insertions(+), 107 deletions(-) create mode 100644 src/hooks/useCurrentMove.ts delete mode 100644 src/sections/analysis/reviewPanelToolbar.tsx create mode 100644 src/sections/analysis/reviewPanelToolbar/flipBoardButton.tsx create mode 100644 src/sections/analysis/reviewPanelToolbar/goToLastPositionButton.tsx create mode 100644 src/sections/analysis/reviewPanelToolbar/index.tsx create mode 100644 src/sections/analysis/reviewPanelToolbar/nextMoveButton.tsx create mode 100644 src/sections/analysis/reviewPanelToolbar/saveButton.tsx diff --git a/src/hooks/useChess.ts b/src/hooks/useChess.ts index 3af609d..fe0e6c3 100644 --- a/src/hooks/useChess.ts +++ b/src/hooks/useChess.ts @@ -1,4 +1,4 @@ -import { Chess } from "chess.js"; +import { Chess, Move } from "chess.js"; import { PrimitiveAtom, useAtom } from "jotai"; export const useChessActions = (chessAtom: PrimitiveAtom) => { @@ -20,10 +20,16 @@ export const useChessActions = (chessAtom: PrimitiveAtom) => { return newGame; }; - const move = (move: { from: string; to: string; promotion?: string }) => { + const move = (move: { + from: string; + to: string; + promotion?: string; + }): Move | null => { const newGame = copyGame(); - newGame.move(move); + const result = newGame.move(move); setGame(newGame); + + return result; }; const undo = () => { diff --git a/src/hooks/useCurrentMove.ts b/src/hooks/useCurrentMove.ts new file mode 100644 index 0000000..f67c89e --- /dev/null +++ b/src/hooks/useCurrentMove.ts @@ -0,0 +1,31 @@ +import { boardAtom, gameAtom, gameEvalAtom } from "@/sections/analysis/states"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; + +export const useCurrentMove = () => { + const gameEval = useAtomValue(gameEvalAtom); + const game = useAtomValue(gameAtom); + const board = useAtomValue(boardAtom); + + const currentEvalMove = useMemo(() => { + if (!gameEval) return undefined; + + const boardHistory = board.history(); + const gameHistory = game.history(); + + if ( + boardHistory.length >= gameHistory.length || + gameHistory.slice(0, boardHistory.length).join() !== boardHistory.join() + ) + return; + + const evalIndex = board.history().length; + return { + ...board.history({ verbose: true }).at(-1), + eval: gameEval.moves[evalIndex], + lastEval: evalIndex > 0 ? gameEval.moves[evalIndex - 1] : undefined, + }; + }, [gameEval, board, game]); + + return currentEvalMove; +}; diff --git a/src/hooks/useGameDatabase.ts b/src/hooks/useGameDatabase.ts index 8eb7405..7227a66 100644 --- a/src/hooks/useGameDatabase.ts +++ b/src/hooks/useGameDatabase.ts @@ -55,9 +55,11 @@ export const useGameDatabase = (shouldFetchGames?: boolean) => { if (!db) throw new Error("Database not initialized"); const gameToAdd = formatGameToDatabase(game); - await db.add("games", gameToAdd as Game); + const gameId = await db.add("games", gameToAdd as Game); loadGames(); + + return gameId; }; const setGameEval = async (gameId: number, evaluation: GameEval) => { diff --git a/src/lib/engine/stockfish.ts b/src/lib/engine/stockfish.ts index a08fe3d..acdb6ac 100644 --- a/src/lib/engine/stockfish.ts +++ b/src/lib/engine/stockfish.ts @@ -1,3 +1,4 @@ +import { Engine } from "@/types/enums"; import { GameEval, LineEval, MoveEval } from "@/types/eval"; export class Stockfish { @@ -25,14 +26,32 @@ export class Stockfish { public async init(): Promise { await this.sendCommands(["uci"], "uciok"); - await this.sendCommands( - ["setoption name MultiPV value 3", "isready"], - "readyok" - ); + await this.setMultiPv(3, false); this.ready = true; console.log("Stockfish initialized"); } + public async setMultiPv(multiPv: number, checkIsReady = true) { + if (checkIsReady) { + this.throwErrorIfNotReady(); + } + + if (multiPv < 1 || multiPv > 6) { + throw new Error(`Invalid MultiPV value : ${multiPv}`); + } + + await this.sendCommands( + [`setoption name MultiPV value ${multiPv}`, "isready"], + "readyok" + ); + } + + private throwErrorIfNotReady() { + if (!this.ready) { + throw new Error("Stockfish is not ready"); + } + } + public shutdown(): void { this.ready = false; this.worker.postMessage("quit"); @@ -64,30 +83,53 @@ export class Stockfish { }); } - public async evaluateGame(fens: string[], depth = 16): Promise { + public async evaluateGame( + fens: string[], + depth = 16, + multiPv = 3 + ): Promise { + this.throwErrorIfNotReady(); this.ready = false; - console.log("Evaluating game"); + + await this.setMultiPv(multiPv, false); await this.sendCommands(["ucinewgame", "isready"], "readyok"); this.worker.postMessage("position startpos"); const moves: MoveEval[] = []; for (const fen of fens) { console.log(`Evaluating position: ${fen}`); - const result = await this.evaluatePosition(fen, depth); + const result = await this.evaluatePosition(fen, depth, false); moves.push(result); } this.ready = true; - console.log("Game evaluated"); console.log(moves); - return { moves, accuracy: { white: 82.34, black: 67.49 } }; // TODO: Calculate accuracy + return { + moves, + accuracy: { white: 82.34, black: 67.49 }, // TODO: Calculate accuracy + settings: { + name: Engine.Stockfish16, + date: new Date().toISOString(), + depth, + multiPv, + }, + }; } - public async evaluatePosition(fen: string, depth = 16): Promise { + public async evaluatePosition( + fen: string, + depth = 16, + checkIsReady = true + ): Promise { + if (checkIsReady) { + this.throwErrorIfNotReady(); + } + const results = await this.sendCommands( [`position fen ${fen}`, `go depth ${depth}`], "bestmove" ); + return this.parseResults(results); } diff --git a/src/pages/database.tsx b/src/pages/database.tsx index 68da31b..4ea547b 100644 --- a/src/pages/database.tsx +++ b/src/pages/database.tsx @@ -12,6 +12,7 @@ import { useCallback, useMemo } from "react"; import { red } from "@mui/material/colors"; import LoadGameButton from "@/sections/loadGame/loadGameButton"; import { useGameDatabase } from "@/hooks/useGameDatabase"; +import { useRouter } from "next/router"; const gridLocaleText: GridLocaleText = { ...GRID_DEFAULT_LOCALE_TEXT, @@ -20,6 +21,9 @@ const gridLocaleText: GridLocaleText = { export default function GameDatabase() { const { games, deleteGame } = useGameDatabase(true); + const router = useRouter(); + + console.log(games); const handleDeleteGameRow = useCallback( (id: GridRowId) => async () => { @@ -77,7 +81,29 @@ export default function GameDatabase() { align: "center", }, { - field: "actions", + field: "openEvaluation", + type: "actions", + headerName: "Analyze", + width: 100, + cellClassName: "actions", + getActions: ({ id }) => { + return [ + + } + label="Open Evaluation" + onClick={() => + router.push({ pathname: "/", query: { gameId: id } }) + } + color="inherit" + key={`${id}-open-eval-button`} + />, + ]; + }, + }, + { + field: "delete", type: "actions", headerName: "Delete", width: 100, @@ -88,7 +114,7 @@ export default function GameDatabase() { icon={ } - label="Supprimer" + label="Delete" onClick={handleDeleteGameRow(id)} color="inherit" key={`${id}-delete-button`} @@ -97,7 +123,7 @@ export default function GameDatabase() { }, }, ], - [handleDeleteGameRow] + [handleDeleteGameRow, router] ); return ( diff --git a/src/sections/analysis/analyzeButton.tsx b/src/sections/analysis/analyzeButton.tsx index a074a4a..cb2195e 100644 --- a/src/sections/analysis/analyzeButton.tsx +++ b/src/sections/analysis/analyzeButton.tsx @@ -5,29 +5,47 @@ import { useEffect, useState } from "react"; import { gameAtom, gameEvalAtom } from "./states"; import { useAtomValue, useSetAtom } from "jotai"; import { getFens } from "@/lib/chess"; +import { useGameDatabase } from "@/hooks/useGameDatabase"; +import { useRouter } from "next/router"; export default function AnalyzeButton() { const [engine, setEngine] = useState(null); + const [evaluationInProgress, setEvaluationInProgress] = useState(false); + const { setGameEval } = useGameDatabase(); const setEval = useSetAtom(gameEvalAtom); const game = useAtomValue(gameAtom); + const router = useRouter(); + const { gameId } = router.query; useEffect(() => { const engine = new Stockfish(); - engine.init(); - setEngine(engine); + engine.init().then(() => { + setEngine(engine); + }); return () => { engine.shutdown(); }; }, []); - const readyToAnalyse = engine?.isReady() && game.history().length > 0; + const readyToAnalyse = + engine?.isReady() && game.history().length > 0 && !evaluationInProgress; const handleAnalyze = async () => { const gameFens = getFens(game); - if (engine?.isReady() && gameFens.length) { - const newGameEval = await engine.evaluateGame(gameFens); - setEval(newGameEval); + if (!engine?.isReady() || gameFens.length === 0 || evaluationInProgress) + return; + + setEvaluationInProgress(true); + + const newGameEval = await engine.evaluateGame(gameFens); + setEval(newGameEval); + + setEvaluationInProgress(false); + + if (typeof gameId === "string") { + setGameEval(parseInt(gameId), newGameEval); + console.log("Game Eval saved to database"); } }; diff --git a/src/sections/analysis/board.tsx b/src/sections/analysis/board.tsx index ef90a03..3744370 100644 --- a/src/sections/analysis/board.tsx +++ b/src/sections/analysis/board.tsx @@ -1,10 +1,56 @@ import { Grid, Typography } from "@mui/material"; import { Chessboard } from "react-chessboard"; import { useAtomValue } from "jotai"; -import { boardAtom } from "./states"; +import { boardAtom, boardOrientationAtom, gameAtom } from "./states"; +import { Arrow, Square } from "react-chessboard/dist/chessboard/types"; +import { useChessActions } from "@/hooks/useChess"; +import { useCurrentMove } from "@/hooks/useCurrentMove"; +import { useMemo } from "react"; export default function Board() { const board = useAtomValue(boardAtom); + const game = useAtomValue(gameAtom); + const boardOrientation = useAtomValue(boardOrientationAtom); + const boardActions = useChessActions(boardAtom); + const currentMove = useCurrentMove(); + + const onPieceDrop = (source: Square, target: Square): boolean => { + try { + const result = boardActions.move({ + from: source, + to: target, + promotion: "q", // TODO: Let the user choose the promotion + }); + + return !!result; + } catch { + return false; + } + }; + + const customArrows: Arrow[] = useMemo(() => { + if (!currentMove?.lastEval) return []; + + const bestMoveArrow = [ + currentMove.lastEval.bestMove.slice(0, 2), + currentMove.lastEval.bestMove.slice(2, 4), + "#3aab18", + ] as Arrow; + + if ( + !currentMove.from || + !currentMove.to || + (currentMove.from === bestMoveArrow[0] && + currentMove.to === bestMoveArrow[1]) + ) { + return [bestMoveArrow]; + } + + return [[currentMove.from, currentMove.to, "#ffaa00"], bestMoveArrow]; + }, [currentMove]); + + const whiteLabel = game.header()["White"] || "White Player (?)"; + const blackLabel = game.header()["Black"] || "Black Player (?)"; return ( - - White Player (?) - + + + {boardOrientation ? blackLabel : whiteLabel} + + - + + + - - Black Player (?) - + + + {boardOrientation ? whiteLabel : blackLabel} + + ); } diff --git a/src/sections/analysis/loadGame.tsx b/src/sections/analysis/loadGame.tsx index adab299..fe8811d 100644 --- a/src/sections/analysis/loadGame.tsx +++ b/src/sections/analysis/loadGame.tsx @@ -1,42 +1,61 @@ import { Grid } from "@mui/material"; import LoadGameButton from "../loadGame/loadGameButton"; import { useRouter } from "next/router"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { useChessActions } from "@/hooks/useChess"; -import { boardAtom, gameAtom } from "./states"; +import { + boardAtom, + boardOrientationAtom, + gameAtom, + gameEvalAtom, +} from "./states"; import { useGameDatabase } from "@/hooks/useGameDatabase"; +import { useAtomValue, useSetAtom } from "jotai"; +import { Chess } from "chess.js"; export default function LoadGame() { const router = useRouter(); const { gameId } = router.query; + const game = useAtomValue(gameAtom); const gameActions = useChessActions(gameAtom); const boardActions = useChessActions(boardAtom); const { getGame } = useGameDatabase(); + const setEval = useSetAtom(gameEvalAtom); + const setBoardOrientation = useSetAtom(boardOrientationAtom); + + const resetAndSetGamePgn = useCallback( + (pgn: string) => { + boardActions.reset(); + setEval(undefined); + setBoardOrientation(true); + gameActions.setPgn(pgn); + }, + [boardActions, gameActions, setEval, setBoardOrientation] + ); useEffect(() => { const loadGame = async () => { if (typeof gameId !== "string") return; - const game = await getGame(parseInt(gameId)); - if (!game) return; + const gamefromDb = await getGame(parseInt(gameId)); + if (!gamefromDb) return; - boardActions.reset(); - gameActions.setPgn(game.pgn); + const gamefromDbChess = new Chess(); + gamefromDbChess.loadPgn(gamefromDb.pgn); + if (game.history().join() === gamefromDbChess.history().join()) return; + + resetAndSetGamePgn(gamefromDb.pgn); + setEval(gamefromDb.eval); }; loadGame(); - }, [gameId]); + }, [gameId, getGame, game, resetAndSetGamePgn, setEval]); if (!router.isReady || gameId) return null; return ( - { - boardActions.reset(); - gameActions.setPgn(game.pgn()); - }} - /> + resetAndSetGamePgn(game.pgn())} /> ); } diff --git a/src/sections/analysis/reviewPanelBody.tsx b/src/sections/analysis/reviewPanelBody.tsx index 5671b94..ae74a17 100644 --- a/src/sections/analysis/reviewPanelBody.tsx +++ b/src/sections/analysis/reviewPanelBody.tsx @@ -1,16 +1,31 @@ import { Icon } from "@iconify/react"; import { Divider, Grid, List, Typography } from "@mui/material"; import { useAtomValue } from "jotai"; -import { boardAtom, gameEvalAtom } from "./states"; +import { boardAtom, gameAtom } from "./states"; import LineEvaluation from "./lineEvaluation"; +import { useCurrentMove } from "@/hooks/useCurrentMove"; export default function ReviewPanelBody() { - const gameEval = useAtomValue(gameEvalAtom); - if (!gameEval) return null; - + const game = useAtomValue(gameAtom); const board = useAtomValue(boardAtom); - const evalIndex = board.history().length; - const moveEval = gameEval.moves[evalIndex]; + + const move = useCurrentMove(); + + const getBestMoveLabel = () => { + const bestMove = move?.lastEval?.bestMove; + if (bestMove) { + return `${bestMove} was the best move`; + } + + const boardHistory = board.history(); + const gameHistory = game.history(); + + if (game.isGameOver() && boardHistory.join() === gameHistory.join()) { + return "Game is over"; + } + + return null; + }; return ( <> @@ -35,12 +50,12 @@ export default function ReviewPanelBody() { - {moveEval ? `${moveEval.bestMove} is the best move` : "Game is over"} + {getBestMoveLabel()} - {moveEval?.lines.map((line) => ( + {move?.eval?.lines.map((line) => ( ))} diff --git a/src/sections/analysis/reviewPanelToolbar.tsx b/src/sections/analysis/reviewPanelToolbar.tsx deleted file mode 100644 index 5ed475a..0000000 --- a/src/sections/analysis/reviewPanelToolbar.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Divider, Grid, IconButton } from "@mui/material"; -import { Icon } from "@iconify/react"; -import { useAtomValue } from "jotai"; -import { boardAtom, gameAtom } from "./states"; -import { useChessActions } from "@/hooks/useChess"; - -export default function ReviewPanelToolBar() { - const game = useAtomValue(gameAtom); - const board = useAtomValue(boardAtom); - const boardActions = useChessActions(boardAtom); - - const addNextMoveToGame = () => { - const nextMoveIndex = board.history().length; - const nextMove = game.history({ verbose: true })[nextMoveIndex]; - - if (nextMove) { - boardActions.move({ - from: nextMove.from, - to: nextMove.to, - promotion: nextMove.promotion, - }); - } - }; - - return ( - <> - - - - - - - boardActions.reset()}> - - - boardActions.undo()}> - - - addNextMoveToGame()}> - - - - - - - - - - - ); -} diff --git a/src/sections/analysis/reviewPanelToolbar/flipBoardButton.tsx b/src/sections/analysis/reviewPanelToolbar/flipBoardButton.tsx new file mode 100644 index 0000000..f26ebe3 --- /dev/null +++ b/src/sections/analysis/reviewPanelToolbar/flipBoardButton.tsx @@ -0,0 +1,14 @@ +import { useSetAtom } from "jotai"; +import { boardOrientationAtom } from "../states"; +import { IconButton } from "@mui/material"; +import { Icon } from "@iconify/react"; + +export default function FlipBoardButton() { + const setBoardOrientation = useSetAtom(boardOrientationAtom); + + return ( + setBoardOrientation((prev) => !prev)}> + + + ); +} diff --git a/src/sections/analysis/reviewPanelToolbar/goToLastPositionButton.tsx b/src/sections/analysis/reviewPanelToolbar/goToLastPositionButton.tsx new file mode 100644 index 0000000..6ea4022 --- /dev/null +++ b/src/sections/analysis/reviewPanelToolbar/goToLastPositionButton.tsx @@ -0,0 +1,28 @@ +import { Icon } from "@iconify/react"; +import { IconButton } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { boardAtom, gameAtom } from "../states"; +import { useChessActions } from "@/hooks/useChess"; + +export default function GoToLastPositionButton() { + const boardActions = useChessActions(boardAtom); + const game = useAtomValue(gameAtom); + const board = useAtomValue(boardAtom); + + const gameHistory = game.history(); + const boardHistory = board.history(); + + const isButtonDisabled = boardHistory >= gameHistory; + + return ( + { + if (isButtonDisabled) return; + boardActions.setPgn(game.pgn()); + }} + disabled={isButtonDisabled} + > + + + ); +} diff --git a/src/sections/analysis/reviewPanelToolbar/index.tsx b/src/sections/analysis/reviewPanelToolbar/index.tsx new file mode 100644 index 0000000..c31b0fc --- /dev/null +++ b/src/sections/analysis/reviewPanelToolbar/index.tsx @@ -0,0 +1,46 @@ +import { Divider, Grid, IconButton } from "@mui/material"; +import { Icon } from "@iconify/react"; +import { useAtomValue } from "jotai"; +import { boardAtom } from "../states"; +import { useChessActions } from "@/hooks/useChess"; +import FlipBoardButton from "./flipBoardButton"; +import NextMoveButton from "./nextMoveButton"; +import GoToLastPositionButton from "./goToLastPositionButton"; +import SaveButton from "./saveButton"; + +export default function ReviewPanelToolBar() { + const board = useAtomValue(boardAtom); + const boardActions = useChessActions(boardAtom); + + const boardHistory = board.history(); + + return ( + <> + + + + + + boardActions.reset()} + disabled={boardHistory.length === 0} + > + + + + boardActions.undo()} + disabled={boardHistory.length === 0} + > + + + + + + + + + + + ); +} diff --git a/src/sections/analysis/reviewPanelToolbar/nextMoveButton.tsx b/src/sections/analysis/reviewPanelToolbar/nextMoveButton.tsx new file mode 100644 index 0000000..8567663 --- /dev/null +++ b/src/sections/analysis/reviewPanelToolbar/nextMoveButton.tsx @@ -0,0 +1,42 @@ +import { Icon } from "@iconify/react"; +import { IconButton } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { boardAtom, gameAtom } from "../states"; +import { useChessActions } from "@/hooks/useChess"; + +export default function NextMoveButton() { + const boardActions = useChessActions(boardAtom); + const game = useAtomValue(gameAtom); + const board = useAtomValue(boardAtom); + + const gameHistory = game.history(); + const boardHistory = board.history(); + + const isButtonEnabled = + boardHistory.length < gameHistory.length && + gameHistory.slice(0, boardHistory.length).join() === boardHistory.join(); + + const addNextGameMoveToBoard = () => { + if (!isButtonEnabled) return; + + const nextMoveIndex = boardHistory.length; + const nextMove = game.history({ verbose: true })[nextMoveIndex]; + + if (nextMove) { + boardActions.move({ + from: nextMove.from, + to: nextMove.to, + promotion: nextMove.promotion, + }); + } + }; + + return ( + addNextGameMoveToBoard()} + disabled={!isButtonEnabled} + > + + + ); +} diff --git a/src/sections/analysis/reviewPanelToolbar/saveButton.tsx b/src/sections/analysis/reviewPanelToolbar/saveButton.tsx new file mode 100644 index 0000000..eafacf2 --- /dev/null +++ b/src/sections/analysis/reviewPanelToolbar/saveButton.tsx @@ -0,0 +1,36 @@ +import { useGameDatabase } from "@/hooks/useGameDatabase"; +import { Icon } from "@iconify/react"; +import { IconButton } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { useRouter } from "next/router"; +import { gameAtom } from "../states"; + +export default function SaveButton() { + const game = useAtomValue(gameAtom); + const { addGame } = useGameDatabase(); + + const router = useRouter(); + const { gameId } = router.query; + + const isButtonEnabled = router.isReady && typeof gameId === undefined; + + return ( + { + if (!isButtonEnabled) return; + const gameId = await addGame(game); + router.replace( + { + query: { gameId: gameId }, + pathname: router.pathname, + }, + undefined, + { shallow: true, scroll: false } + ); + }} + disabled={!isButtonEnabled} + > + + + ); +} diff --git a/src/sections/analysis/states.ts b/src/sections/analysis/states.ts index 59bee2d..88c84a0 100644 --- a/src/sections/analysis/states.ts +++ b/src/sections/analysis/states.ts @@ -5,3 +5,4 @@ import { atom } from "jotai"; export const gameEvalAtom = atom(undefined); export const gameAtom = atom(new Chess()); export const boardAtom = atom(new Chess()); +export const boardOrientationAtom = atom(true); diff --git a/src/sections/layout/NavBar.tsx b/src/sections/layout/NavBar.tsx index b535adf..357daa1 100644 --- a/src/sections/layout/NavBar.tsx +++ b/src/sections/layout/NavBar.tsx @@ -26,10 +26,8 @@ export default function NavBar({ darkMode, switchDarkMode }: Props) { theme.zIndex.drawer + 1, - backgroundColor: "primary.main", - }} + sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }} + enableColorOnDark >