feat : add live position evaluation

This commit is contained in:
GuillaumeSD
2024-02-24 21:05:45 +01:00
parent 7b328d3159
commit 1f748f99ca
8 changed files with 220 additions and 105 deletions

View File

@@ -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 { MoveEval } from "@/types/eval";
import { Move } from "chess.js"; import { Move } from "chess.js";
import { useAtomValue } from "jotai"; 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<Move> & { export type CurrentMove = Partial<Move> & {
eval?: MoveEval; eval?: MoveEval;
@@ -10,35 +18,50 @@ export type CurrentMove = Partial<Move> & {
}; };
export const useCurrentMove = () => { export const useCurrentMove = () => {
const [currentMove, setCurrentMove] = useState<CurrentMove>({});
const engine = useEngine(EngineName.Stockfish16);
const gameEval = useAtomValue(gameEvalAtom); const gameEval = useAtomValue(gameEvalAtom);
const game = useAtomValue(gameAtom); const game = useAtomValue(gameAtom);
const board = useAtomValue(boardAtom); const board = useAtomValue(boardAtom);
const depth = useAtomValue(engineDepthAtom);
const multiPv = useAtomValue(engineMultiPvAtom);
const currentMove: CurrentMove = useMemo(() => { useEffect(() => {
const move = { const move: CurrentMove = {
...board.history({ verbose: true }).at(-1), ...board.history({ verbose: true }).at(-1),
}; };
if (!gameEval) return move; if (gameEval) {
const boardHistory = board.history();
const gameHistory = game.history();
const boardHistory = board.history(); if (
const gameHistory = game.history(); boardHistory.length <= gameHistory.length &&
gameHistory.slice(0, boardHistory.length).join() === boardHistory.join()
) {
const evalIndex = board.history().length;
if ( move.eval = gameEval.moves[evalIndex];
boardHistory.length <= gameHistory.length && move.lastEval =
gameHistory.slice(0, boardHistory.length).join() === boardHistory.join() evalIndex > 0 ? gameEval.moves[evalIndex - 1] : undefined;
) { }
const evalIndex = board.history().length;
return {
...move,
eval: gameEval.moves[evalIndex],
lastEval: evalIndex > 0 ? gameEval.moves[evalIndex - 1] : undefined,
};
} }
return move; if (!move.eval && engine?.isReady()) {
}, [gameEval, board, game]); 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; return currentMove;
}; };

View File

@@ -57,3 +57,23 @@ export const getGameToSave = (game: Chess, board: Chess): Chess => {
return board; 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;
}
};
};

View File

@@ -1,5 +1,10 @@
import { EngineName } from "@/types/enums"; import { EngineName } from "@/types/enums";
import { GameEval, LineEval, MoveEval } from "@/types/eval"; import {
EvaluatePositionWithUpdateParams,
GameEval,
LineEval,
MoveEval,
} from "@/types/eval";
export abstract class UciEngine { export abstract class UciEngine {
private worker: Worker; private worker: Worker;
@@ -18,15 +23,15 @@ export abstract class UciEngine {
public async init(): Promise<void> { public async init(): Promise<void> {
await this.sendCommands(["uci"], "uciok"); await this.sendCommands(["uci"], "uciok");
await this.setMultiPv(this.multiPv, false); await this.setMultiPv(this.multiPv, true);
this.ready = true; this.ready = true;
console.log(`${this.engineName} initialized`); console.log(`${this.engineName} initialized`);
} }
public async setMultiPv(multiPv: number, checkIsReady = true) { private async setMultiPv(multiPv: number, initCase = false) {
if (multiPv === this.multiPv) return; if (!initCase) {
if (multiPv === this.multiPv) return;
if (checkIsReady) {
this.throwErrorIfNotReady(); this.throwErrorIfNotReady();
} }
@@ -59,15 +64,23 @@ export abstract class UciEngine {
return this.ready; return this.ready;
} }
private async stopSearch(): Promise<void> {
await this.sendCommands(["stop", "isready"], "readyok");
}
private async sendCommands( private async sendCommands(
commands: string[], commands: string[],
finalMessage: string finalMessage: string,
onNewMessage?: (messages: string[]) => void
): Promise<string[]> { ): Promise<string[]> {
return new Promise((resolve) => { return new Promise((resolve) => {
const messages: string[] = []; const messages: string[] = [];
this.worker.onmessage = (event) => { this.worker.onmessage = (event) => {
const messageData: string = event.data; const messageData: string = event.data;
messages.push(messageData); messages.push(messageData);
onNewMessage?.(messages);
if (messageData.startsWith(finalMessage)) { if (messageData.startsWith(finalMessage)) {
resolve(messages); resolve(messages);
} }
@@ -85,6 +98,7 @@ export abstract class UciEngine {
multiPv = this.multiPv multiPv = this.multiPv
): Promise<GameEval> { ): Promise<GameEval> {
this.throwErrorIfNotReady(); this.throwErrorIfNotReady();
await this.setMultiPv(multiPv);
this.ready = false; this.ready = false;
await this.sendCommands(["ucinewgame", "isready"], "readyok"); await this.sendCommands(["ucinewgame", "isready"], "readyok");
@@ -92,7 +106,7 @@ export abstract class UciEngine {
const moves: MoveEval[] = []; const moves: MoveEval[] = [];
for (const fen of fens) { for (const fen of fens) {
const result = await this.evaluatePosition(fen, depth, multiPv, false); const result = await this.evaluatePosition(fen, depth);
moves.push(result); moves.push(result);
} }
@@ -109,44 +123,46 @@ export abstract class UciEngine {
}; };
} }
public async evaluatePosition( private async evaluatePosition(fen: string, depth = 16): Promise<MoveEval> {
fen: string,
depth = 16,
multiPv = this.multiPv,
checkIsReady = true
): Promise<MoveEval> {
if (checkIsReady) {
this.throwErrorIfNotReady();
}
await this.setMultiPv(multiPv, checkIsReady);
console.log(`Evaluating position: ${fen}`); console.log(`Evaluating position: ${fen}`);
const results = await this.sendCommands( const results = await this.sendCommands(
[`position fen ${fen}`, `go depth ${depth}`], [`position fen ${fen}`, `go depth ${depth}`],
"bestmove" "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<void> {
this.throwErrorIfNotReady();
await this.stopSearch();
await this.setMultiPv(multiPv);
const whiteToPlay = fen.split(" ")[1] === "w"; const whiteToPlay = fen.split(" ")[1] === "w";
if (!whiteToPlay) { const onNewMessage = (messages: string[]) => {
const lines = parsedResults.lines.map((line) => ({ const parsedResults = this.parseResults(messages, whiteToPlay);
...line, setPartialEval(parsedResults);
cp: line.cp ? -line.cp : line.cp, };
}));
return { console.log(`Evaluating position: ${fen}`);
...parsedResults, await this.sendCommands(
lines, [`position fen ${fen}`, `go depth ${depth}`],
}; "bestmove",
} onNewMessage
);
return parsedResults;
} }
private parseResults(results: string[]): MoveEval { private parseResults(results: string[], whiteToPlay: boolean): MoveEval {
const parsedResults: MoveEval = { const parsedResults: MoveEval = {
bestMove: "", bestMove: "",
lines: [], lines: [],
@@ -189,6 +205,13 @@ export abstract class UciEngine {
parsedResults.lines = Object.values(tempResults).sort(this.sortLines); 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; return parsedResults;
} }

View File

@@ -1,11 +1,15 @@
import { LineEval } from "@/types/eval"; 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 { interface Props {
line: LineEval; line: LineEval;
} }
export default function LineEvaluation({ line }: Props) { export default function LineEvaluation({ line }: Props) {
const board = useAtomValue(boardAtom);
const lineLabel = const lineLabel =
line.cp !== undefined line.cp !== undefined
? `${line.cp / 100}` ? `${line.cp / 100}`
@@ -13,10 +17,32 @@ export default function LineEvaluation({ line }: Props) {
? `Mate in ${Math.abs(line.mate)}` ? `Mate in ${Math.abs(line.mate)}`
: "?"; : "?";
const showSkeleton = line.depth === 0;
return ( return (
<ListItem disablePadding> <ListItem disablePadding>
<ListItemText primary={lineLabel} sx={{ marginRight: 2 }} /> <Typography marginRight={2} marginY={0.5}>
<Typography>{line.pv.slice(0, 7).join(", ")}</Typography> {showSkeleton ? (
<Skeleton
width={"2em"}
variant="rounded"
animation="wave"
sx={{ color: "transparent" }}
>
placeholder
</Skeleton>
) : (
lineLabel
)}
</Typography>
<Typography>
{showSkeleton ? (
<Skeleton width={"30em"} variant="rounded" animation="wave" />
) : (
line.pv.slice(0, 10).map(moveLineUciToSan(board.fen())).join(", ")
)}
</Typography>
</ListItem> </ListItem>
); );
} }

View File

@@ -1,11 +1,13 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { Divider, Grid, List, Typography } from "@mui/material"; import { Divider, Grid, List, Typography } from "@mui/material";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { boardAtom, gameAtom } from "./states"; import { boardAtom, engineMultiPvAtom, gameAtom } from "./states";
import LineEvaluation from "./lineEvaluation"; import LineEvaluation from "./lineEvaluation";
import { useCurrentMove } from "@/hooks/useCurrentMove"; import { useCurrentMove } from "@/hooks/useCurrentMove";
import { LineEval } from "@/types/eval";
export default function ReviewPanelBody() { export default function ReviewPanelBody() {
const linesNumber = useAtomValue(engineMultiPvAtom);
const move = useCurrentMove(); const move = useCurrentMove();
const game = useAtomValue(gameAtom); const game = useAtomValue(gameAtom);
const board = useAtomValue(boardAtom); const board = useAtomValue(boardAtom);
@@ -17,6 +19,14 @@ export default function ReviewPanelBody() {
const isGameOver = const isGameOver =
gameHistory.length > 0 && boardHistory.join() === gameHistory.join(); 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 ( return (
<> <>
<Divider sx={{ width: "90%", marginY: 3 }} /> <Divider sx={{ width: "90%", marginY: 3 }} />
@@ -57,8 +67,8 @@ export default function ReviewPanelBody() {
<Grid item container xs={12} justifyContent="center" alignItems="center"> <Grid item container xs={12} justifyContent="center" alignItems="center">
<List> <List>
{move?.eval?.lines.map((line) => ( {engineLines.map((line) => (
<LineEvaluation key={line.pv[0]} line={line} /> <LineEvaluation key={line.multiPv} line={line} />
))} ))}
</List> </List>
</Grid> </Grid>

View File

@@ -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 (
<Grid
container
item
justifyContent="space-evenly"
alignItems="center"
xs={12}
marginY={3}
gap={3}
>
<FormControlLabel
control={
<Checkbox
checked={showBestMove}
onChange={(_, checked) => setShowBestMove(checked)}
disabled={!gameEval}
/>
}
label="Show best move green arrow"
sx={{ marginX: 0 }}
/>
<FormControlLabel
control={
<Checkbox
checked={showPlayerMove}
onChange={(_, checked) => setShowPlayerMove(checked)}
/>
}
label="Show player move yellow arrow"
sx={{ marginX: 0 }}
/>
</Grid>
);
}

View File

@@ -1,27 +1,15 @@
import { import { Divider, Grid, IconButton, Tooltip } from "@mui/material";
Checkbox,
Divider,
FormControlLabel,
Grid,
IconButton,
Tooltip,
} from "@mui/material";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useAtom, useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { import { boardAtom } from "../states";
boardAtom,
showBestMoveArrowAtom,
showPlayerMoveArrowAtom,
} from "../states";
import { useChessActions } from "@/hooks/useChess"; import { useChessActions } from "@/hooks/useChess";
import FlipBoardButton from "./flipBoardButton"; import FlipBoardButton from "./flipBoardButton";
import NextMoveButton from "./nextMoveButton"; import NextMoveButton from "./nextMoveButton";
import GoToLastPositionButton from "./goToLastPositionButton"; import GoToLastPositionButton from "./goToLastPositionButton";
import SaveButton from "./saveButton"; import SaveButton from "./saveButton";
import ArrowOptions from "./arrowOptions";
export default function ReviewPanelToolBar() { export default function ReviewPanelToolBar() {
const [showBestMove, setShowBestMove] = useAtom(showBestMoveArrowAtom);
const [showPlayerMove, setShowPlayerMove] = useAtom(showPlayerMoveArrowAtom);
const board = useAtomValue(boardAtom); const board = useAtomValue(boardAtom);
const boardActions = useChessActions(boardAtom); const boardActions = useChessActions(boardAtom);
@@ -63,36 +51,7 @@ export default function ReviewPanelToolBar() {
<SaveButton /> <SaveButton />
</Grid> </Grid>
<Grid <ArrowOptions />
container
item
justifyContent="space-evenly"
alignItems="center"
xs={12}
marginY={3}
gap={3}
>
<FormControlLabel
control={
<Checkbox
checked={showBestMove}
onChange={(_, checked) => setShowBestMove(checked)}
/>
}
label="Show best move green arrow"
sx={{ marginX: 0 }}
/>
<FormControlLabel
control={
<Checkbox
checked={showPlayerMove}
onChange={(_, checked) => setShowPlayerMove(checked)}
/>
}
label="Show player move yellow arrow"
sx={{ marginX: 0 }}
/>
</Grid>
</> </>
); );
} }

View File

@@ -30,3 +30,10 @@ export interface GameEval {
accuracy: Accuracy; accuracy: Accuracy;
settings: EngineSettings; settings: EngineSettings;
} }
export interface EvaluatePositionWithUpdateParams {
fen: string;
depth?: number;
multiPv?: number;
setPartialEval: (moveEval: MoveEval) => void;
}