feat : add move classification

This commit is contained in:
GuillaumeSD
2024-03-04 01:56:17 +01:00
parent 9d11b0006e
commit 4975ecfdd1
22 changed files with 13872 additions and 89 deletions

13606
src/data/openings.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,18 @@
import { LineEval } from "@/types/eval"; import { EvaluateGameParams, LineEval } from "@/types/eval";
import { Game } from "@/types/game"; import { Game } from "@/types/game";
import { Chess } from "chess.js"; import { Chess } from "chess.js";
export const getFens = (game: Chess): string[] => { export const getEvaluateGameParams = (game: Chess): EvaluateGameParams => {
const history = game.history({ verbose: true }); const history = game.history({ verbose: true });
const fens = history.map((move) => move.before); const fens = history.map((move) => move.before);
fens.push(history[history.length - 1].after); fens.push(history[history.length - 1].after);
return fens; const uciMoves = history.map(
(move) => move.from + move.to + (move.promotion || "")
);
return { fens, uciMoves };
}; };
export const getGameFromPgn = (pgn: string): Chess => { export const getGameFromPgn = (pgn: string): Chess => {

View File

@@ -5,6 +5,7 @@ import {
getWeightedMean, getWeightedMean,
} from "@/lib/helpers"; } from "@/lib/helpers";
import { Accuracy, MoveEval } from "@/types/eval"; import { Accuracy, MoveEval } from "@/types/eval";
import { getPositionWinPercentage } from "./winPercentage";
export const computeAccuracy = (moves: MoveEval[]): Accuracy => { export const computeAccuracy = (moves: MoveEval[]): Accuracy => {
const movesWinPercentage = moves.map(getPositionWinPercentage); const movesWinPercentage = moves.map(getPositionWinPercentage);
@@ -85,27 +86,3 @@ const getMovesAccuracy = (movesWinPercentage: number[]): number[] =>
return Math.min(100, Math.max(0, rawAccuracy + 1)); return Math.min(100, Math.max(0, rawAccuracy + 1));
}); });
const getPositionWinPercentage = (move: MoveEval): number => {
if (move.lines[0].cp !== undefined) {
return getWinPercentageFromCp(move.lines[0].cp);
}
if (move.lines[0].mate !== undefined) {
return getWinPercentageFromMate(move.lines[0].mate);
}
throw new Error("No cp or mate in move");
};
const getWinPercentageFromMate = (mate: number): number => {
const mateInf = mate * Infinity;
return getWinPercentageFromCp(mateInf);
};
const getWinPercentageFromCp = (cp: number): number => {
const cpCeiled = ceilsNumber(cp, -1000, 1000);
const MULTIPLIER = -0.00368208;
const winChances = 2 / (1 + Math.exp(MULTIPLIER * cpCeiled)) - 1;
return 50 + 50 * winChances;
};

View File

@@ -0,0 +1,72 @@
import { MoveEval } from "@/types/eval";
import { getPositionWinPercentage } from "./winPercentage";
import { MoveClassification } from "@/types/enums";
import { openings } from "@/data/openings";
export const getMovesClassification = (
rawMoves: MoveEval[],
uciMoves: string[],
fens: string[]
): MoveEval[] => {
const positionsWinPercentage = rawMoves.map(getPositionWinPercentage);
let currentOpening: string | undefined = undefined;
const moves = rawMoves.map((rawMove, index) => {
if (index === 0) return rawMove;
const currentFen = fens[index].split(" ")[0];
const opening = openings.find((opening) => opening.fen === currentFen);
if (opening) {
currentOpening = opening.name;
return {
...rawMove,
opening: opening.name,
moveClassification: MoveClassification.Book,
};
}
const uciMove = uciMoves[index - 1];
const bestMove = rawMoves[index - 1].bestMove;
if (uciMove === bestMove) {
return {
...rawMove,
opening: currentOpening,
moveClassification: MoveClassification.Best,
};
}
const lastPositionWinPercentage = positionsWinPercentage[index - 1];
const positionWinPercentage = positionsWinPercentage[index];
const isWhiteMove = index % 2 === 1;
const moveClassification = getMoveClassification(
lastPositionWinPercentage,
positionWinPercentage,
isWhiteMove
);
return {
...rawMove,
opening: currentOpening,
moveClassification,
};
});
return moves;
};
const getMoveClassification = (
lastPositionWinPercentage: number,
positionWinPercentage: number,
isWhiteMove: boolean
): MoveClassification => {
const winPercentageDiff =
(positionWinPercentage - lastPositionWinPercentage) *
(isWhiteMove ? 1 : -1);
if (winPercentageDiff < -15) return MoveClassification.Blunder;
if (winPercentageDiff < -10) return MoveClassification.Mistake;
if (winPercentageDiff < -5) return MoveClassification.Inaccuracy;
if (winPercentageDiff < 0) return MoveClassification.Good;
return MoveClassification.Excellent;
};

View File

@@ -5,7 +5,6 @@ export const parseEvaluationResults = (
whiteToPlay: boolean whiteToPlay: boolean
): MoveEval => { ): MoveEval => {
const parsedResults: MoveEval = { const parsedResults: MoveEval = {
bestMove: "",
lines: [], lines: [],
}; };
const tempResults: Record<string, LineEval> = {}; const tempResults: Record<string, LineEval> = {};

View File

@@ -0,0 +1,26 @@
import { ceilsNumber } from "@/lib/helpers";
import { MoveEval } from "@/types/eval";
export const getPositionWinPercentage = (move: MoveEval): number => {
if (move.lines[0].cp !== undefined) {
return getWinPercentageFromCp(move.lines[0].cp);
}
if (move.lines[0].mate !== undefined) {
return getWinPercentageFromMate(move.lines[0].mate);
}
throw new Error("No cp or mate in move");
};
const getWinPercentageFromMate = (mate: number): number => {
const mateInf = mate * Infinity;
return getWinPercentageFromCp(mateInf);
};
const getWinPercentageFromCp = (cp: number): number => {
const cpCeiled = ceilsNumber(cp, -1000, 1000);
const MULTIPLIER = -0.00368208;
const winChances = 2 / (1 + Math.exp(MULTIPLIER * cpCeiled)) - 1;
return 50 + 50 * winChances;
};

View File

@@ -1,5 +1,6 @@
import { EngineName } from "@/types/enums"; import { EngineName } from "@/types/enums";
import { import {
EvaluateGameParams,
EvaluatePositionWithUpdateParams, EvaluatePositionWithUpdateParams,
GameEval, GameEval,
MoveEval, MoveEval,
@@ -8,6 +9,7 @@ import { parseEvaluationResults } from "./helpers/parseResults";
import { computeAccuracy } from "./helpers/accuracy"; import { computeAccuracy } from "./helpers/accuracy";
import { getWhoIsCheckmated } from "../chess"; import { getWhoIsCheckmated } from "../chess";
import { getLichessEval } from "../lichess"; import { getLichessEval } from "../lichess";
import { getMovesClassification } from "./helpers/moveClassification";
export abstract class UciEngine { export abstract class UciEngine {
private worker: Worker; private worker: Worker;
@@ -93,11 +95,12 @@ export abstract class UciEngine {
}); });
} }
public async evaluateGame( public async evaluateGame({
fens: string[], fens,
uciMoves,
depth = 16, depth = 16,
multiPv = this.multiPv multiPv = this.multiPv,
): Promise<GameEval> { }: EvaluateGameParams): Promise<GameEval> {
this.throwErrorIfNotReady(); this.throwErrorIfNotReady();
await this.setMultiPv(multiPv); await this.setMultiPv(multiPv);
this.ready = false; this.ready = false;
@@ -110,7 +113,6 @@ export abstract class UciEngine {
const whoIsCheckmated = getWhoIsCheckmated(fen); const whoIsCheckmated = getWhoIsCheckmated(fen);
if (whoIsCheckmated) { if (whoIsCheckmated) {
moves.push({ moves.push({
bestMove: "",
lines: [ lines: [
{ {
pv: [], pv: [],
@@ -126,11 +128,16 @@ export abstract class UciEngine {
moves.push(result); moves.push(result);
} }
const movesWithClassification = getMovesClassification(
moves,
uciMoves,
fens
);
const accuracy = computeAccuracy(moves); const accuracy = computeAccuracy(moves);
this.ready = true; this.ready = true;
return { return {
moves: moves.slice(0, -1), moves: movesWithClassification,
accuracy, accuracy,
settings: { settings: {
engine: this.engineName, engine: this.engineName,

View File

@@ -37,9 +37,25 @@ export const getLichessEval = async (
lines.sort(sortLines); lines.sort(sortLines);
const bestMove = lines[0].pv[0];
const linesToKeep = lines.slice(0, multiPv);
const isWhiteToPlay = fen.split(" ")[1] === "w";
if (!isWhiteToPlay) {
return { return {
bestMove: lines[0].pv[0], bestMove,
lines: lines.slice(0, multiPv), lines: linesToKeep.map((line) => ({
...line,
cp: line.cp ? -line.cp : line.cp,
mate: line.mate ? -line.mate : line.mate,
})),
};
}
return {
bestMove,
lines: linesToKeep,
}; };
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -1,4 +1,4 @@
import { useChessActions } from "@/hooks/useChess"; import { useChessActions } from "@/hooks/useChessActions";
import Board from "@/sections/analysis/board"; import Board from "@/sections/analysis/board";
import ReviewPanelBody from "@/sections/analysis/reviewPanelBody"; import ReviewPanelBody from "@/sections/analysis/reviewPanelBody";
import ReviewPanelHeader from "@/sections/analysis/reviewPanelHeader"; import ReviewPanelHeader from "@/sections/analysis/reviewPanelHeader";
@@ -55,6 +55,7 @@ export default function GameReport() {
backgroundColor: "secondary.main", backgroundColor: "secondary.main",
borderColor: "primary.main", borderColor: "primary.main",
borderWidth: 2, borderWidth: 2,
boxShadow: "0 2px 10px rgba(0, 0, 0, 0.5)",
}} }}
padding={3} padding={3}
rowGap={3} rowGap={3}

View File

@@ -9,7 +9,7 @@ import {
showPlayerMoveArrowAtom, showPlayerMoveArrowAtom,
} from "../states"; } from "../states";
import { Arrow, Square } from "react-chessboard/dist/chessboard/types"; import { Arrow, Square } from "react-chessboard/dist/chessboard/types";
import { useChessActions } from "@/hooks/useChess"; import { useChessActions } from "@/hooks/useChessActions";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import PlayerInfo from "./playerInfo"; import PlayerInfo from "./playerInfo";
import EvaluationBar from "./evaluationBar"; import EvaluationBar from "./evaluationBar";
@@ -25,12 +25,16 @@ export default function Board() {
const { makeMove: makeBoardMove } = useChessActions(boardAtom); const { makeMove: makeBoardMove } = useChessActions(boardAtom);
const currentMove = useAtomValue(currentMoveAtom); const currentMove = useAtomValue(currentMoveAtom);
const onPieceDrop = (source: Square, target: Square): boolean => { const onPieceDrop = (
source: Square,
target: Square,
piece: string
): boolean => {
try { try {
const result = makeBoardMove({ const result = makeBoardMove({
from: source, from: source,
to: target, to: target,
promotion: "q", // TODO: Let the user choose the promotion promotion: piece[1]?.toLowerCase() ?? "q",
}); });
return !!result; return !!result;
@@ -42,7 +46,7 @@ export default function Board() {
const customArrows: Arrow[] = useMemo(() => { const customArrows: Arrow[] = useMemo(() => {
const arrows: Arrow[] = []; const arrows: Arrow[] = [];
if (currentMove?.lastEval && showBestMoveArrow) { if (currentMove?.lastEval?.bestMove && showBestMoveArrow) {
const bestMoveArrow = [ const bestMoveArrow = [
currentMove.lastEval.bestMove.slice(0, 2), currentMove.lastEval.bestMove.slice(0, 2),
currentMove.lastEval.bestMove.slice(2, 4), currentMove.lastEval.bestMove.slice(2, 4),
@@ -108,7 +112,10 @@ export default function Board() {
onPieceDrop={onPieceDrop} onPieceDrop={onPieceDrop}
boardOrientation={boardOrientation ? "white" : "black"} boardOrientation={boardOrientation ? "white" : "black"}
customArrows={customArrows} customArrows={customArrows}
customBoardStyle={{ borderRadius: "5px" }} customBoardStyle={{
borderRadius: "5px",
boxShadow: "0 2px 10px rgba(0, 0, 0, 0.5)",
}}
/> />
</Grid> </Grid>

View File

@@ -1,30 +0,0 @@
import { useCurrentMove } from "@/hooks/useCurrentMove";
import { Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { boardAtom } from "../states";
import { useMemo } from "react";
import { moveLineUciToSan } from "@/lib/chess";
export default function BestMove() {
const move = useCurrentMove();
const board = useAtomValue(boardAtom);
const bestMove = move?.lastEval?.bestMove;
const bestMoveSan = useMemo(() => {
if (!bestMove) return undefined;
const lastPosition = board.history({ verbose: true }).at(-1)?.before;
if (!lastPosition) return undefined;
return moveLineUciToSan(lastPosition)(bestMove);
}, [bestMove, board]);
if (!bestMoveSan) return null;
return (
<Grid item xs={12}>
<Typography align="center">{`${bestMoveSan} was the best move`}</Typography>
</Grid>
);
}

View File

@@ -8,7 +8,8 @@ import { LineEval } from "@/types/eval";
import { EngineName } from "@/types/enums"; import { EngineName } from "@/types/enums";
import EngineSettingsButton from "@/sections/engineSettings/engineSettingsButton"; import EngineSettingsButton from "@/sections/engineSettings/engineSettingsButton";
import Accuracies from "./accuracies"; import Accuracies from "./accuracies";
import BestMove from "./bestMove"; import MoveInfo from "./moveInfo";
import Opening from "./opening";
export default function ReviewPanelBody() { export default function ReviewPanelBody() {
const linesNumber = useAtomValue(engineMultiPvAtom); const linesNumber = useAtomValue(engineMultiPvAtom);
@@ -20,7 +21,10 @@ export default function ReviewPanelBody() {
const gameHistory = game.history(); const gameHistory = game.history();
const isGameOver = const isGameOver =
gameHistory.length > 0 && boardHistory.join() === gameHistory.join(); boardHistory.length > 0 &&
(board.isCheckmate() ||
board.isDraw() ||
boardHistory.join() === gameHistory.join());
const linesSkeleton: LineEval[] = Array.from({ length: linesNumber }).map( const linesSkeleton: LineEval[] = Array.from({ length: linesNumber }).map(
(_, i) => ({ pv: [`${i}`], depth: 0, multiPv: i + 1 }) (_, i) => ({ pv: [`${i}`], depth: 0, multiPv: i + 1 })
@@ -73,7 +77,9 @@ export default function ReviewPanelBody() {
<Accuracies /> <Accuracies />
<BestMove /> <MoveInfo />
<Opening />
{isGameOver && ( {isGameOver && (
<Grid item xs={12}> <Grid item xs={12}>

View File

@@ -0,0 +1,53 @@
import { useCurrentMove } from "@/hooks/useCurrentMove";
import { Grid, Typography } from "@mui/material";
import { useAtomValue } from "jotai";
import { boardAtom } from "../states";
import { useMemo } from "react";
import { moveLineUciToSan } from "@/lib/chess";
import { MoveClassification } from "@/types/enums";
export default function MoveInfo() {
const move = useCurrentMove();
const board = useAtomValue(boardAtom);
const bestMove = move?.lastEval?.bestMove;
const bestMoveSan = useMemo(() => {
if (!bestMove) return undefined;
const lastPosition = board.history({ verbose: true }).at(-1)?.before;
if (!lastPosition) return undefined;
return moveLineUciToSan(lastPosition)(bestMove);
}, [bestMove, board]);
if (!bestMoveSan) return null;
const moveClassification = move.eval?.moveClassification;
const moveLabel = moveClassification
? `${move.san} is ${moveClassificationLabels[moveClassification]}`
: null;
const bestMoveLabel =
moveClassification === MoveClassification.Best ||
moveClassification === MoveClassification.Book
? null
: `${bestMoveSan} was the best move`;
return (
<Grid item container columnGap={5} xs={12} justifyContent="center">
{moveLabel && <Typography align="center">{moveLabel}</Typography>}
{bestMoveLabel && <Typography align="center">{bestMoveLabel}</Typography>}
</Grid>
);
}
const moveClassificationLabels: Record<MoveClassification, string> = {
[MoveClassification.Blunder]: "a blunder",
[MoveClassification.Mistake]: "a mistake",
[MoveClassification.Inaccuracy]: "an inaccuracy",
[MoveClassification.Good]: "good",
[MoveClassification.Excellent]: "excellent",
[MoveClassification.Best]: "the best move",
[MoveClassification.Book]: "an opening move",
};

View File

@@ -0,0 +1,15 @@
import { useCurrentMove } from "@/hooks/useCurrentMove";
import { Grid, Typography } from "@mui/material";
export default function Opening() {
const move = useCurrentMove();
const opening = move?.eval?.opening;
if (!opening) return null;
return (
<Grid item xs={12}>
<Typography align="center">{opening}</Typography>
</Grid>
);
}

View File

@@ -7,7 +7,7 @@ import {
gameEvalAtom, gameEvalAtom,
} from "../states"; } from "../states";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { getFens } from "@/lib/chess"; import { getEvaluateGameParams } from "@/lib/chess";
import { useGameDatabase } from "@/hooks/useGameDatabase"; import { useGameDatabase } from "@/hooks/useGameDatabase";
import { LoadingButton } from "@mui/lab"; import { LoadingButton } from "@mui/lab";
import { useEngine } from "@/hooks/useEngine"; import { useEngine } from "@/hooks/useEngine";
@@ -27,18 +27,22 @@ export default function AnalyzeButton() {
engine?.isReady() && game.history().length > 0 && !evaluationInProgress; engine?.isReady() && game.history().length > 0 && !evaluationInProgress;
const handleAnalyze = async () => { const handleAnalyze = async () => {
const gameFens = getFens(game); const params = getEvaluateGameParams(game);
if (!engine?.isReady() || gameFens.length === 0 || evaluationInProgress) { if (
!engine?.isReady() ||
params.fens.length === 0 ||
evaluationInProgress
) {
return; return;
} }
setEvaluationInProgress(true); setEvaluationInProgress(true);
const newGameEval = await engine.evaluateGame( const newGameEval = await engine.evaluateGame({
gameFens, ...params,
engineDepth, depth: engineDepth,
engineMultiPv multiPv: engineMultiPv,
); });
setEval(newGameEval); setEval(newGameEval);
setEvaluationInProgress(false); setEvaluationInProgress(false);

View File

@@ -1,6 +1,6 @@
import LoadGameButton from "../../loadGame/loadGameButton"; import LoadGameButton from "../../loadGame/loadGameButton";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useChessActions } from "@/hooks/useChess"; import { useChessActions } from "@/hooks/useChessActions";
import { import {
boardAtom, boardAtom,
boardOrientationAtom, boardOrientationAtom,

View File

@@ -2,7 +2,7 @@ import { Icon } from "@iconify/react";
import { Grid, IconButton, Tooltip } from "@mui/material"; import { Grid, IconButton, Tooltip } from "@mui/material";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { boardAtom, gameAtom } from "../states"; import { boardAtom, gameAtom } from "../states";
import { useChessActions } from "@/hooks/useChess"; import { useChessActions } from "@/hooks/useChessActions";
export default function GoToLastPositionButton() { export default function GoToLastPositionButton() {
const { setPgn: setBoardPgn } = useChessActions(boardAtom); const { setPgn: setBoardPgn } = useChessActions(boardAtom);

View File

@@ -2,7 +2,7 @@ import { Grid, IconButton, Tooltip } from "@mui/material";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { boardAtom } from "../states"; import { boardAtom } from "../states";
import { useChessActions } from "@/hooks/useChess"; import { useChessActions } from "@/hooks/useChessActions";
import FlipBoardButton from "./flipBoardButton"; import FlipBoardButton from "./flipBoardButton";
import NextMoveButton from "./nextMoveButton"; import NextMoveButton from "./nextMoveButton";
import GoToLastPositionButton from "./goToLastPositionButton"; import GoToLastPositionButton from "./goToLastPositionButton";

View File

@@ -2,7 +2,7 @@ import { Icon } from "@iconify/react";
import { Grid, IconButton, Tooltip } from "@mui/material"; import { Grid, IconButton, Tooltip } from "@mui/material";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { boardAtom, gameAtom } from "../states"; import { boardAtom, gameAtom } from "../states";
import { useChessActions } from "@/hooks/useChess"; import { useChessActions } from "@/hooks/useChessActions";
export default function NextMoveButton() { export default function NextMoveButton() {
const { makeMove: makeBoardMove } = useChessActions(boardAtom); const { makeMove: makeBoardMove } = useChessActions(boardAtom);

View File

@@ -6,3 +6,13 @@ export enum GameOrigin {
export enum EngineName { export enum EngineName {
Stockfish16 = "stockfish_16", Stockfish16 = "stockfish_16",
} }
export enum MoveClassification {
Blunder = "blunder",
Mistake = "mistake",
Inaccuracy = "inaccuracy",
Good = "good",
Excellent = "excellent",
Best = "best",
Book = "book",
}

View File

@@ -1,8 +1,10 @@
import { Move } from "chess.js"; import { Move } from "chess.js";
import { EngineName } from "./enums"; import { EngineName, MoveClassification } from "./enums";
export interface MoveEval { export interface MoveEval {
bestMove: string; bestMove?: string;
moveClassification?: MoveClassification;
opening?: string;
lines: LineEval[]; lines: LineEval[];
} }
@@ -43,3 +45,10 @@ export type CurrentMove = Partial<Move> & {
eval?: MoveEval; eval?: MoveEval;
lastEval?: MoveEval; lastEval?: MoveEval;
}; };
export interface EvaluateGameParams {
fens: string[];
uciMoves: string[];
depth?: number;
multiPv?: number;
}