diff --git a/src/hooks/useCurrentMove.ts b/src/hooks/useCurrentMove.ts index 8dfb5bd..5383561 100644 --- a/src/hooks/useCurrentMove.ts +++ b/src/hooks/useCurrentMove.ts @@ -1,8 +1,16 @@ -import { boardAtom, gameAtom, gameEvalAtom } from "@/sections/analysis/states"; +import { + boardAtom, + engineDepthAtom, + engineMultiPvAtom, + gameAtom, + gameEvalAtom, +} from "@/sections/analysis/states"; import { MoveEval } from "@/types/eval"; import { Move } from "chess.js"; import { useAtomValue } from "jotai"; -import { useMemo } from "react"; +import { useEffect, useState } from "react"; +import { useEngine } from "./useEngine"; +import { EngineName } from "@/types/enums"; export type CurrentMove = Partial & { eval?: MoveEval; @@ -10,35 +18,50 @@ export type CurrentMove = Partial & { }; export const useCurrentMove = () => { + const [currentMove, setCurrentMove] = useState({}); + const engine = useEngine(EngineName.Stockfish16); const gameEval = useAtomValue(gameEvalAtom); const game = useAtomValue(gameAtom); const board = useAtomValue(boardAtom); + const depth = useAtomValue(engineDepthAtom); + const multiPv = useAtomValue(engineMultiPvAtom); - const currentMove: CurrentMove = useMemo(() => { - const move = { + useEffect(() => { + const move: CurrentMove = { ...board.history({ verbose: true }).at(-1), }; - if (!gameEval) return move; + if (gameEval) { + const boardHistory = board.history(); + const gameHistory = game.history(); - const boardHistory = board.history(); - const gameHistory = game.history(); + if ( + boardHistory.length <= gameHistory.length && + gameHistory.slice(0, boardHistory.length).join() === boardHistory.join() + ) { + const evalIndex = board.history().length; - if ( - boardHistory.length <= gameHistory.length && - gameHistory.slice(0, boardHistory.length).join() === boardHistory.join() - ) { - const evalIndex = board.history().length; - - return { - ...move, - eval: gameEval.moves[evalIndex], - lastEval: evalIndex > 0 ? gameEval.moves[evalIndex - 1] : undefined, - }; + move.eval = gameEval.moves[evalIndex]; + move.lastEval = + evalIndex > 0 ? gameEval.moves[evalIndex - 1] : undefined; + } } - return move; - }, [gameEval, board, game]); + if (!move.eval && engine?.isReady()) { + const setPartialEval = (moveEval: MoveEval) => { + setCurrentMove({ ...move, eval: moveEval }); + }; + + engine.evaluatePositionWithUpdate({ + fen: board.fen(), + depth, + multiPv, + setPartialEval, + }); + } + + setCurrentMove(move); + }, [gameEval, board, game, engine, depth, multiPv]); return currentMove; }; diff --git a/src/lib/chess.ts b/src/lib/chess.ts index 70ddaef..587b649 100644 --- a/src/lib/chess.ts +++ b/src/lib/chess.ts @@ -57,3 +57,23 @@ export const getGameToSave = (game: Chess, board: Chess): Chess => { return board; }; + +export const moveLineUciToSan = ( + fen: string +): ((moveUci: string) => string) => { + const game = new Chess(fen); + + return (moveUci: string): string => { + try { + const move = game.move({ + from: moveUci.slice(0, 2), + to: moveUci.slice(2, 4), + promotion: moveUci.slice(4, 5) || undefined, + }); + + return move.san; + } catch (e) { + return moveUci; + } + }; +}; diff --git a/src/lib/engine/uciEngine.ts b/src/lib/engine/uciEngine.ts index bc4f11a..9582458 100644 --- a/src/lib/engine/uciEngine.ts +++ b/src/lib/engine/uciEngine.ts @@ -1,5 +1,10 @@ import { EngineName } from "@/types/enums"; -import { GameEval, LineEval, MoveEval } from "@/types/eval"; +import { + EvaluatePositionWithUpdateParams, + GameEval, + LineEval, + MoveEval, +} from "@/types/eval"; export abstract class UciEngine { private worker: Worker; @@ -18,15 +23,15 @@ export abstract class UciEngine { public async init(): Promise { await this.sendCommands(["uci"], "uciok"); - await this.setMultiPv(this.multiPv, false); + await this.setMultiPv(this.multiPv, true); this.ready = true; console.log(`${this.engineName} initialized`); } - public async setMultiPv(multiPv: number, checkIsReady = true) { - if (multiPv === this.multiPv) return; + private async setMultiPv(multiPv: number, initCase = false) { + if (!initCase) { + if (multiPv === this.multiPv) return; - if (checkIsReady) { this.throwErrorIfNotReady(); } @@ -59,15 +64,23 @@ export abstract class UciEngine { return this.ready; } + private async stopSearch(): Promise { + await this.sendCommands(["stop", "isready"], "readyok"); + } + private async sendCommands( commands: string[], - finalMessage: string + finalMessage: string, + onNewMessage?: (messages: string[]) => void ): Promise { return new Promise((resolve) => { const messages: string[] = []; + this.worker.onmessage = (event) => { const messageData: string = event.data; messages.push(messageData); + onNewMessage?.(messages); + if (messageData.startsWith(finalMessage)) { resolve(messages); } @@ -85,6 +98,7 @@ export abstract class UciEngine { multiPv = this.multiPv ): Promise { this.throwErrorIfNotReady(); + await this.setMultiPv(multiPv); this.ready = false; await this.sendCommands(["ucinewgame", "isready"], "readyok"); @@ -92,7 +106,7 @@ export abstract class UciEngine { const moves: MoveEval[] = []; for (const fen of fens) { - const result = await this.evaluatePosition(fen, depth, multiPv, false); + const result = await this.evaluatePosition(fen, depth); moves.push(result); } @@ -109,44 +123,46 @@ export abstract class UciEngine { }; } - public async evaluatePosition( - fen: string, - depth = 16, - multiPv = this.multiPv, - checkIsReady = true - ): Promise { - if (checkIsReady) { - this.throwErrorIfNotReady(); - } - - await this.setMultiPv(multiPv, checkIsReady); - + private async evaluatePosition(fen: string, depth = 16): Promise { console.log(`Evaluating position: ${fen}`); + const results = await this.sendCommands( [`position fen ${fen}`, `go depth ${depth}`], "bestmove" ); - const parsedResults = this.parseResults(results); + const whiteToPlay = fen.split(" ")[1] === "w"; + + return this.parseResults(results, whiteToPlay); + } + + public async evaluatePositionWithUpdate({ + fen, + depth = 16, + multiPv = this.multiPv, + setPartialEval, + }: EvaluatePositionWithUpdateParams): Promise { + this.throwErrorIfNotReady(); + + await this.stopSearch(); + await this.setMultiPv(multiPv); const whiteToPlay = fen.split(" ")[1] === "w"; - if (!whiteToPlay) { - const lines = parsedResults.lines.map((line) => ({ - ...line, - cp: line.cp ? -line.cp : line.cp, - })); + const onNewMessage = (messages: string[]) => { + const parsedResults = this.parseResults(messages, whiteToPlay); + setPartialEval(parsedResults); + }; - return { - ...parsedResults, - lines, - }; - } - - return parsedResults; + console.log(`Evaluating position: ${fen}`); + await this.sendCommands( + [`position fen ${fen}`, `go depth ${depth}`], + "bestmove", + onNewMessage + ); } - private parseResults(results: string[]): MoveEval { + private parseResults(results: string[], whiteToPlay: boolean): MoveEval { const parsedResults: MoveEval = { bestMove: "", lines: [], @@ -189,6 +205,13 @@ export abstract class UciEngine { parsedResults.lines = Object.values(tempResults).sort(this.sortLines); + if (!whiteToPlay) { + parsedResults.lines = parsedResults.lines.map((line) => ({ + ...line, + cp: line.cp ? -line.cp : line.cp, + })); + } + return parsedResults; } diff --git a/src/sections/analysis/lineEvaluation.tsx b/src/sections/analysis/lineEvaluation.tsx index a32e081..b497c9e 100644 --- a/src/sections/analysis/lineEvaluation.tsx +++ b/src/sections/analysis/lineEvaluation.tsx @@ -1,11 +1,15 @@ import { LineEval } from "@/types/eval"; -import { ListItem, ListItemText, Typography } from "@mui/material"; +import { ListItem, Skeleton, Typography } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { boardAtom } from "./states"; +import { moveLineUciToSan } from "@/lib/chess"; interface Props { line: LineEval; } export default function LineEvaluation({ line }: Props) { + const board = useAtomValue(boardAtom); const lineLabel = line.cp !== undefined ? `${line.cp / 100}` @@ -13,10 +17,32 @@ export default function LineEvaluation({ line }: Props) { ? `Mate in ${Math.abs(line.mate)}` : "?"; + const showSkeleton = line.depth === 0; + return ( - - {line.pv.slice(0, 7).join(", ")} + + {showSkeleton ? ( + + placeholder + + ) : ( + lineLabel + )} + + + + {showSkeleton ? ( + + ) : ( + line.pv.slice(0, 10).map(moveLineUciToSan(board.fen())).join(", ") + )} + ); } diff --git a/src/sections/analysis/reviewPanelBody.tsx b/src/sections/analysis/reviewPanelBody.tsx index b98f1fe..0dbf8e5 100644 --- a/src/sections/analysis/reviewPanelBody.tsx +++ b/src/sections/analysis/reviewPanelBody.tsx @@ -1,11 +1,13 @@ import { Icon } from "@iconify/react"; import { Divider, Grid, List, Typography } from "@mui/material"; import { useAtomValue } from "jotai"; -import { boardAtom, gameAtom } from "./states"; +import { boardAtom, engineMultiPvAtom, gameAtom } from "./states"; import LineEvaluation from "./lineEvaluation"; import { useCurrentMove } from "@/hooks/useCurrentMove"; +import { LineEval } from "@/types/eval"; export default function ReviewPanelBody() { + const linesNumber = useAtomValue(engineMultiPvAtom); const move = useCurrentMove(); const game = useAtomValue(gameAtom); const board = useAtomValue(boardAtom); @@ -17,6 +19,14 @@ export default function ReviewPanelBody() { const isGameOver = gameHistory.length > 0 && boardHistory.join() === gameHistory.join(); + const linesSkeleton: LineEval[] = Array.from({ length: linesNumber }).map( + (_, i) => ({ pv: [`${i}`], depth: 0, multiPv: i + 1 }) + ); + + const engineLines = move?.eval?.lines.length + ? move.eval.lines + : linesSkeleton; + return ( <> @@ -57,8 +67,8 @@ export default function ReviewPanelBody() { - {move?.eval?.lines.map((line) => ( - + {engineLines.map((line) => ( + ))} diff --git a/src/sections/analysis/reviewPanelToolbar/arrowOptions.tsx b/src/sections/analysis/reviewPanelToolbar/arrowOptions.tsx new file mode 100644 index 0000000..5f4d43c --- /dev/null +++ b/src/sections/analysis/reviewPanelToolbar/arrowOptions.tsx @@ -0,0 +1,47 @@ +import { Checkbox, FormControlLabel, Grid } from "@mui/material"; +import { useAtom, useAtomValue } from "jotai"; +import { + gameEvalAtom, + showBestMoveArrowAtom, + showPlayerMoveArrowAtom, +} from "../states"; + +export default function ArrowOptions() { + const gameEval = useAtomValue(gameEvalAtom); + const [showBestMove, setShowBestMove] = useAtom(showBestMoveArrowAtom); + const [showPlayerMove, setShowPlayerMove] = useAtom(showPlayerMoveArrowAtom); + + return ( + + setShowBestMove(checked)} + disabled={!gameEval} + /> + } + label="Show best move green arrow" + sx={{ marginX: 0 }} + /> + setShowPlayerMove(checked)} + /> + } + label="Show player move yellow arrow" + sx={{ marginX: 0 }} + /> + + ); +} diff --git a/src/sections/analysis/reviewPanelToolbar/index.tsx b/src/sections/analysis/reviewPanelToolbar/index.tsx index a7e4e42..f37a569 100644 --- a/src/sections/analysis/reviewPanelToolbar/index.tsx +++ b/src/sections/analysis/reviewPanelToolbar/index.tsx @@ -1,27 +1,15 @@ -import { - Checkbox, - Divider, - FormControlLabel, - Grid, - IconButton, - Tooltip, -} from "@mui/material"; +import { Divider, Grid, IconButton, Tooltip } from "@mui/material"; import { Icon } from "@iconify/react"; -import { useAtom, useAtomValue } from "jotai"; -import { - boardAtom, - showBestMoveArrowAtom, - showPlayerMoveArrowAtom, -} from "../states"; +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"; +import ArrowOptions from "./arrowOptions"; export default function ReviewPanelToolBar() { - const [showBestMove, setShowBestMove] = useAtom(showBestMoveArrowAtom); - const [showPlayerMove, setShowPlayerMove] = useAtom(showPlayerMoveArrowAtom); const board = useAtomValue(boardAtom); const boardActions = useChessActions(boardAtom); @@ -63,36 +51,7 @@ export default function ReviewPanelToolBar() { - - setShowBestMove(checked)} - /> - } - label="Show best move green arrow" - sx={{ marginX: 0 }} - /> - setShowPlayerMove(checked)} - /> - } - label="Show player move yellow arrow" - sx={{ marginX: 0 }} - /> - + ); } diff --git a/src/types/eval.ts b/src/types/eval.ts index 89744af..60637e4 100644 --- a/src/types/eval.ts +++ b/src/types/eval.ts @@ -30,3 +30,10 @@ export interface GameEval { accuracy: Accuracy; settings: EngineSettings; } + +export interface EvaluatePositionWithUpdateParams { + fen: string; + depth?: number; + multiPv?: number; + setPartialEval: (moveEval: MoveEval) => void; +}