From a8da159870e487c8c84208bf4e4b3e54262e31a9 Mon Sep 17 00:00:00 2001 From: GuillaumeSD Date: Wed, 28 Feb 2024 23:29:30 +0100 Subject: [PATCH] feat : add accuracy calculation --- src/lib/chess.ts | 12 +- src/lib/engine/helpers/accuracy.ts | 111 ++++ src/lib/engine/helpers/parseResults.ts | 99 ++++ src/lib/engine/uciEngine.ts | 124 +---- src/lib/helpers.ts | 34 ++ .../analysis/reviewPanelBody/index.tsx | 7 +- styles/global.css | 51 -- styles/index.css | 522 ------------------ 8 files changed, 283 insertions(+), 677 deletions(-) create mode 100644 src/lib/engine/helpers/accuracy.ts create mode 100644 src/lib/engine/helpers/parseResults.ts delete mode 100644 styles/global.css delete mode 100644 styles/index.css diff --git a/src/lib/chess.ts b/src/lib/chess.ts index e8a50ca..4fdf930 100644 --- a/src/lib/chess.ts +++ b/src/lib/chess.ts @@ -3,7 +3,11 @@ import { Game } from "@/types/game"; import { Chess } from "chess.js"; export const getFens = (game: Chess): string[] => { - return game.history({ verbose: true }).map((move) => move.before); + const history = game.history({ verbose: true }); + const fens = history.map((move) => move.before); + fens.push(history[history.length - 1].after); + + return fens; }; export const getGameFromPgn = (pgn: string): Chess => { @@ -106,3 +110,9 @@ export const getEvaluationBarValue = ( return { whiteBarPercentage, label: pEval.toFixed(1) }; }; + +export const getWhoIsCheckmated = (fen: string): "w" | "b" | null => { + const game = new Chess(fen); + if (!game.isCheckmate()) return null; + return game.turn(); +}; diff --git a/src/lib/engine/helpers/accuracy.ts b/src/lib/engine/helpers/accuracy.ts new file mode 100644 index 0000000..42d6ee8 --- /dev/null +++ b/src/lib/engine/helpers/accuracy.ts @@ -0,0 +1,111 @@ +import { + ceilsNumber, + getHarmonicMean, + getStandardDeviation, + getWeightedMean, +} from "@/lib/helpers"; +import { Accuracy, MoveEval } from "@/types/eval"; + +export const computeAccuracy = (moves: MoveEval[]): Accuracy => { + const movesWinPercentage = moves.map(getPositionWinPercentage); + + const weights = getAccuracyWeights(movesWinPercentage); + + const movesAccuracy = getMovesAccuracy(movesWinPercentage); + + const whiteAccuracy = getPlayerAccuracy(movesAccuracy, weights, "white"); + const blackAccuracy = getPlayerAccuracy(movesAccuracy, weights, "black"); + + return { + white: whiteAccuracy, + black: blackAccuracy, + }; +}; + +const getPlayerAccuracy = ( + movesAccuracy: number[], + weights: number[], + player: "white" | "black" +): number => { + const remainder = player === "white" ? 0 : 1; + const playerAccuracies = movesAccuracy.filter( + (_, index) => index % 2 === remainder + ); + const playerWeights = weights.filter((_, index) => index % 2 === remainder); + + const weightedMean = getWeightedMean(playerAccuracies, playerWeights); + const harmonicMean = getHarmonicMean(playerAccuracies); + + return (weightedMean + harmonicMean) / 2; +}; + +const getAccuracyWeights = (movesWinPercentage: number[]): number[] => { + const windowSize = ceilsNumber( + Math.ceil(movesWinPercentage.length / 10), + 2, + 8 + ); + + const windows: number[][] = []; + const halfWindowSize = Math.round(windowSize / 2); + + for (let i = 1; i < movesWinPercentage.length; i++) { + const startIdx = i - halfWindowSize; + const endIdx = i + halfWindowSize; + + if (startIdx < 0) { + windows.push(movesWinPercentage.slice(0, windowSize)); + continue; + } + + if (endIdx > movesWinPercentage.length) { + windows.push(movesWinPercentage.slice(-windowSize)); + continue; + } + + windows.push(movesWinPercentage.slice(startIdx, endIdx)); + } + + const weights = windows.map((window) => { + const std = getStandardDeviation(window); + return ceilsNumber(std, 0.5, 12); + }); + + return weights; +}; + +const getMovesAccuracy = (movesWinPercentage: number[]): number[] => + movesWinPercentage.slice(1).map((winPercent, index) => { + const lastWinPercent = movesWinPercentage[index]; + const winDiff = Math.abs(lastWinPercent - winPercent); + + const rawAccuracy = + 103.1668100711649 * Math.exp(-0.04354415386753951 * winDiff) - + 3.166924740191411; + + 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; +}; diff --git a/src/lib/engine/helpers/parseResults.ts b/src/lib/engine/helpers/parseResults.ts new file mode 100644 index 0000000..3f8cef7 --- /dev/null +++ b/src/lib/engine/helpers/parseResults.ts @@ -0,0 +1,99 @@ +import { LineEval, MoveEval } from "@/types/eval"; + +export const parseEvaluationResults = ( + results: string[], + whiteToPlay: boolean +): MoveEval => { + const parsedResults: MoveEval = { + bestMove: "", + lines: [], + }; + const tempResults: Record = {}; + + for (const result of results) { + if (result.startsWith("bestmove")) { + const bestMove = getResultProperty(result, "bestmove"); + if (bestMove) { + parsedResults.bestMove = bestMove; + } + } + + if (result.startsWith("info")) { + const pv = getResultPv(result); + const multiPv = getResultProperty(result, "multipv"); + const depth = getResultProperty(result, "depth"); + if (!pv || !multiPv || !depth) continue; + + if ( + tempResults[multiPv] && + parseInt(depth) < tempResults[multiPv].depth + ) { + continue; + } + + const cp = getResultProperty(result, "cp"); + const mate = getResultProperty(result, "mate"); + + tempResults[multiPv] = { + pv, + cp: cp ? parseInt(cp) : undefined, + mate: mate ? parseInt(mate) : undefined, + depth: parseInt(depth), + multiPv: parseInt(multiPv), + }; + } + } + + parsedResults.lines = Object.values(tempResults).sort(sortLines); + + if (!whiteToPlay) { + parsedResults.lines = parsedResults.lines.map((line) => ({ + ...line, + cp: line.cp ? -line.cp : line.cp, + mate: line.mate ? -line.mate : line.mate, + })); + } + + return parsedResults; +}; + +const sortLines = (a: LineEval, b: LineEval): number => { + if (a.mate !== undefined && b.mate !== undefined) { + return a.mate - b.mate; + } + + if (a.mate !== undefined) { + return -a.mate; + } + + if (b.mate !== undefined) { + return b.mate; + } + + return (b.cp ?? 0) - (a.cp ?? 0); +}; + +const getResultProperty = ( + result: string, + property: string +): string | undefined => { + const splitResult = result.split(" "); + const propertyIndex = splitResult.indexOf(property); + + if (propertyIndex === -1 || propertyIndex + 1 >= splitResult.length) { + return undefined; + } + + return splitResult[propertyIndex + 1]; +}; + +const getResultPv = (result: string): string[] | undefined => { + const splitResult = result.split(" "); + const pvIndex = splitResult.indexOf("pv"); + + if (pvIndex === -1 || pvIndex + 1 >= splitResult.length) { + return undefined; + } + + return splitResult.slice(pvIndex + 1); +}; diff --git a/src/lib/engine/uciEngine.ts b/src/lib/engine/uciEngine.ts index b2f3232..e9ad36a 100644 --- a/src/lib/engine/uciEngine.ts +++ b/src/lib/engine/uciEngine.ts @@ -2,9 +2,11 @@ import { EngineName } from "@/types/enums"; import { EvaluatePositionWithUpdateParams, GameEval, - LineEval, MoveEval, } from "@/types/eval"; +import { parseEvaluationResults } from "./helpers/parseResults"; +import { computeAccuracy } from "./helpers/accuracy"; +import { getWhoIsCheckmated } from "../chess"; export abstract class UciEngine { private worker: Worker; @@ -104,14 +106,31 @@ export abstract class UciEngine { const moves: MoveEval[] = []; for (const fen of fens) { + const whoIsCheckmated = getWhoIsCheckmated(fen); + if (whoIsCheckmated) { + moves.push({ + bestMove: "", + lines: [ + { + pv: [], + depth: 0, + multiPv: 1, + mate: whoIsCheckmated === "w" ? -1 : 1, + }, + ], + }); + continue; + } const result = await this.evaluatePosition(fen, depth); moves.push(result); } + const accuracy = computeAccuracy(moves); + this.ready = true; return { - moves, - accuracy: { white: 82.34, black: 67.49 }, // TODO: Calculate accuracy + moves: moves.slice(0, -1), + accuracy, settings: { engine: this.engineName, date: new Date().toISOString(), @@ -131,7 +150,7 @@ export abstract class UciEngine { const whiteToPlay = fen.split(" ")[1] === "w"; - return this.parseResults(results, whiteToPlay); + return parseEvaluationResults(results, whiteToPlay); } public async evaluatePositionWithUpdate({ @@ -148,7 +167,7 @@ export abstract class UciEngine { const whiteToPlay = fen.split(" ")[1] === "w"; const onNewMessage = (messages: string[]) => { - const parsedResults = this.parseResults(messages, whiteToPlay); + const parsedResults = parseEvaluationResults(messages, whiteToPlay); setPartialEval(parsedResults); }; @@ -159,99 +178,4 @@ export abstract class UciEngine { onNewMessage ); } - - private parseResults(results: string[], whiteToPlay: boolean): MoveEval { - const parsedResults: MoveEval = { - bestMove: "", - lines: [], - }; - const tempResults: Record = {}; - - for (const result of results) { - if (result.startsWith("bestmove")) { - const bestMove = this.getResultProperty(result, "bestmove"); - if (bestMove) { - parsedResults.bestMove = bestMove; - } - } - - if (result.startsWith("info")) { - const pv = this.getResultPv(result); - const multiPv = this.getResultProperty(result, "multipv"); - const depth = this.getResultProperty(result, "depth"); - if (!pv || !multiPv || !depth) continue; - - if ( - tempResults[multiPv] && - parseInt(depth) < tempResults[multiPv].depth - ) { - continue; - } - - const cp = this.getResultProperty(result, "cp"); - const mate = this.getResultProperty(result, "mate"); - - tempResults[multiPv] = { - pv, - cp: cp ? parseInt(cp) : undefined, - mate: mate ? parseInt(mate) : undefined, - depth: parseInt(depth), - multiPv: parseInt(multiPv), - }; - } - } - - parsedResults.lines = Object.values(tempResults).sort(this.sortLines); - - if (!whiteToPlay) { - parsedResults.lines = parsedResults.lines.map((line) => ({ - ...line, - cp: line.cp ? -line.cp : line.cp, - mate: line.mate ? -line.mate : line.mate, - })); - } - - return parsedResults; - } - - private sortLines(a: LineEval, b: LineEval): number { - if (a.mate !== undefined && b.mate !== undefined) { - return a.mate - b.mate; - } - - if (a.mate !== undefined) { - return -a.mate; - } - - if (b.mate !== undefined) { - return b.mate; - } - - return (b.cp ?? 0) - (a.cp ?? 0); - } - - private getResultProperty( - result: string, - property: string - ): string | undefined { - const splitResult = result.split(" "); - const propertyIndex = splitResult.indexOf(property); - - if (propertyIndex === -1 || propertyIndex + 1 >= splitResult.length) { - return undefined; - } - - return splitResult[propertyIndex + 1]; - } - - private getResultPv(result: string): string[] | undefined { - const splitResult = result.split(" "); - const pvIndex = splitResult.indexOf("pv"); - - if (pvIndex === -1 || pvIndex + 1 >= splitResult.length) { - return undefined; - } - - return splitResult.slice(pvIndex + 1); - } } diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 5596efe..4a6d4e1 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -5,3 +5,37 @@ export const getPaddedMonth = (month: number) => { export const capitalize = (s: string) => { return s.charAt(0).toUpperCase() + s.slice(1); }; + +export const ceilsNumber = (number: number, min: number, max: number) => { + if (number > max) return max; + if (number < min) return min; + return number; +}; + +export const getHarmonicMean = (array: number[]) => { + const sum = array.reduce((acc, curr) => acc + 1 / curr, 0); + return array.length / sum; +}; + +export const getStandardDeviation = (array: number[]) => { + const n = array.length; + const mean = array.reduce((a, b) => a + b) / n; + return Math.sqrt( + array.map((x) => Math.pow(x - mean, 2)).reduce((a, b) => a + b) / n + ); +}; + +export const getWeightedMean = (array: number[], weights: number[]) => { + if (array.length > weights.length) + throw new Error("Weights array is too short"); + + const weightedSum = array.reduce( + (acc, curr, index) => acc + curr * weights[index], + 0 + ); + const weightSum = weights + .slice(0, array.length) + .reduce((acc, curr) => acc + curr, 0); + + return weightedSum / weightSum; +}; diff --git a/src/sections/analysis/reviewPanelBody/index.tsx b/src/sections/analysis/reviewPanelBody/index.tsx index 2693c3f..440ebe7 100644 --- a/src/sections/analysis/reviewPanelBody/index.tsx +++ b/src/sections/analysis/reviewPanelBody/index.tsx @@ -86,9 +86,10 @@ export default function ReviewPanelBody() { - {engineLines.map((line) => ( - - ))} + {!board.isCheckmate() && + engineLines.map((line) => ( + + ))} diff --git a/styles/global.css b/styles/global.css deleted file mode 100644 index 4996df1..0000000 --- a/styles/global.css +++ /dev/null @@ -1,51 +0,0 @@ -:root { - --border-color: rgb(63, 63, 63); - - --primary-color: rgb(24, 24, 24); - --secondary-color: rgb(47, 47, 47); - - --success-color: rgb(69, 238, 50); - --success-color-accent: rgb(47, 207, 29); - - --rounded-radius: 10px; -} - -body { - margin: 0; - background-color: var(--primary-color); - overflow-x: hidden; -} - -.center { - display: flex; - justify-content: center; - align-items: center; -} - -* { - font-family: system-ui; -} - -.std-btn { - border: none; - border-radius: 5px; - - cursor: pointer; - - transition: box-shadow 0.3s ease, transform 0.3s ease; -} - -.success-btn { - background-color: var(--success-color); - box-shadow: 0px 8px var(--success-color-accent); - transition: background-color 0.3s ease; -} - -.success-btn:hover { - background-color: var(--success-color-accent); -} - -.success-btn:active { - box-shadow: 0px 2px var(--success-color-accent); - transform: translateY(2px); -} diff --git a/styles/index.css b/styles/index.css deleted file mode 100644 index 119bc8e..0000000 --- a/styles/index.css +++ /dev/null @@ -1,522 +0,0 @@ -.white { - color: white; - margin: 10px 0 0 0; -} - -@media (min-width: 1000px) and (min-height: 860px) { - #review-container { - display: grid; - - grid-template-columns: 68vw 28vw; - grid-template-rows: 90vh; - } - - #review-panel { - margin: 20px; - } -} - -@media (max-width: 999px), (max-height: 860px) { - #review-container { - display: flex; - flex-direction: column; - gap: 20px; - } - - #review-panel { - display: flex; - flex-direction: column; - } - - #secondary-message { - width: 60%; - } - - #review-panel-toolbar { - padding: 10px; - } -} - -/* - REPORT CARDS CONTAINER -*/ -#report-cards { - flex-direction: column; - align-items: center; - - width: 100%; -} - -/* - ACCURACY PERCENTAGES -*/ -#accuracies-title { - margin-bottom: 5px; -} - -#accuracies { - display: flex; - justify-content: center; - gap: 10px; -} - -#accuracies b { - padding: 8px; - border: 2px solid var(--border-color); -} - -#white-accuracy { - background-color: white; -} - -#black-accuracy { - background-color: var(--primary-color); - color: white; -} - -/* - CLASSIFICATION MESSAGE - e.g "Nd4 is a great move" -*/ -#classification-message-container { - display: flex; - justify-content: center; - align-items: center; - gap: 5px; - - margin: 5px; -} - -#classification-message { - font-size: 18px; -} - -#top-alternative-message { - color: #98bc49; -} - -/* - ENGINE SUGGESTIONS -*/ -#engine-suggestions { - display: flex; - flex-direction: column; - align-items: center; -} - -#engine-suggestions-title { - margin-top: 0; - margin-bottom: 5px; -} - -.engine-suggestion { - display: flex; - justify-content: center; - gap: 10px; - - margin: 5px; - - font-size: 18px; -} - -.engine-suggestion b { - padding: 0 3px; - text-align: center; - border-radius: 5px; -} - -/* - OPENING NAME -*/ -#opening-name { - text-align: center; -} - -.profile { - color: white; - font-size: 20px; - margin: 5px 0; -} - -#board-outer-container { - gap: 30px; -} - -#board-inner-container { - flex-direction: column; -} - -#evaluation-bar { - background-color: white; - border: 2px solid var(--border-color); - border-radius: var(--rounded-radius); -} - -#board { - grid-column: 1 / 2; - - max-width: 100%; - - border: 2px solid var(--border-color); - border-radius: 10px; - border-radius: var(--rounded-radius); -} - -/* - DIALOG CONTAINER (SPANS ENTIRE SCREEN) -*/ -#game-select-menu-container { - display: none; - justify-content: center; - align-items: center; - position: fixed; - top: 0; - - width: 100vw; - height: 100vh; - - background-color: rgba(0, 0, 0, 0.7); -} - -/* - MODAL -*/ -#game-select-menu { - display: flex; - flex-direction: column; - align-items: center; - padding: 15px; - - min-width: 400px; - width: 50vw; - - border-radius: 10px; - background-color: var(--primary-color); - color: white; -} - -/* - MODAL TITLE -*/ -#game-select-menu h1 { - margin: 0 0 10px 0; -} - -/* - GAMES LIST TIME PERIOD -*/ -#game-select-period { - margin-bottom: 10px; - font-size: 24px; -} - -/* - GAMES LIST CONTAINER -*/ -#games-list { - display: flex; - flex-direction: column; - align-items: center; - padding: 5px 20px; - - width: 90%; - max-height: 420px; - overflow-x: hidden; - overflow-y: auto; - - background-color: #232323; -} - -#games-list::-webkit-scrollbar { - background-color: #0e0e0e; -} - -#games-list::-webkit-scrollbar-thumb { - background-color: whitesmoke; -} - -/* - GAME LISTING -*/ -.game-listing { - display: flex; - justify-content: space-between; - align-items: center; - gap: 20px; - margin: 5px; - padding: 5px; - - width: 100%; - - background-color: var(--secondary-color); - cursor: pointer; -} - -/* - PAGINATION BUTTONS -*/ -#game-select-page-buttons button { - margin: 10px 5px 0 5px; -} - -/* - CANCEL SELECTION BUTTON -*/ -#game-select-cancel-button { - margin-top: 10px; - - background-color: whitesmoke; - box-shadow: 0px 4px 0px #c7c7c7; - - font-size: 18px; - - transition: box-shadow 0.3s ease, transform 0.3s ease; -} - -#game-select-cancel-button:active { - box-shadow: 0px 2px 0px #c7c7c7; - transform: translateY(2px); -} - -/* - ENTIRE REVIEW PANEL -*/ -#review-panel { - display: grid; - - grid-template-columns: 100%; - - background-color: var(--secondary-color); - - border: 2px solid var(--border-color); - border-radius: var(--rounded-radius); -} - -@media (min-width: 1264px) { - #review-panel { - grid-template-rows: 88% 12%; - } -} - -@media (max-width: 1264px) and (min-width: 1000px) { - #review-panel { - grid-template-rows: 82% 18%; - } -} - -@media (max-width: 999px) { - #review-panel { - grid-template-rows: 82% 18%; - } -} - -@media (max-height: 860px) { - #review-panel { - grid-template-rows: 82% 18%; - } -} - -/* - REVIEW PANEL NON-TOOLBAR SECTION -*/ -#review-panel-main { - display: flex; - flex-direction: column; - align-items: center; - - height: 100%; - - padding: 10px; -} - -@media (max-width: 1265px), (max-height: 860px) { - #review-panel-main { - padding-bottom: 30px; - } -} - -/* - REVIEW PANEL HEADING -*/ -#review-panel-title { - margin: 0px 0px 10px 0px; - text-align: center; -} - -@media (max-width: 1126px) { - #review-panel-title { - font-size: 27px; - } -} - -/* - GAME LOAD TYPE DROPDOWN -*/ -#load-type-dropdown-container { - margin-bottom: 20px; -} - -#load-type-dropdown { - text-align: center; - border-radius: 5px; - background-color: var(--border-color); - color: white; -} - -/* - PGN INPUT TEXTAREA -*/ -#pgn { - width: 90%; - height: 130px; - resize: none; - - border-radius: var(--rounded-radius); - background-color: var(--border-color); - outline: none; - padding: 10px; -} - -/* - CHESS.COM / LICHES USERNAME INPUT TEXTAREA -*/ -#chess-site-username-container { - display: flex; - justify-content: center; - align-items: center; - gap: 5px; - width: 90%; -} - -#chess-site-username { - width: 100%; - height: 30px; - - border-radius: 10px; - background-color: var(--border-color); - - resize: none; - font-size: 16px; - overflow: hidden; -} - -#fetch-account-games-button { - transform: translateY(-2px); -} - -#fetch-account-games-button:active { - transform: translateY(0px); -} - -/* - ANALYSE GREEN BUTTON -*/ -#review-button { - display: flex; - align-items: center; - gap: 10px; - - margin: 10px; - padding: 10px 50px; - - font-size: 20px; - - text-shadow: 0px 1px 2px black; -} - -#review-button img { - filter: drop-shadow(0px 1px 1px black); -} - -/* - SEARCH DEPTH SLIDER -*/ -#depth-slider-container { - display: flex; - justify-content: center; - width: 80%; -} - -#depth-slider { - width: 70%; -} - -#depth-message { - margin: 0px; - text-align: center; -} - -/* - ANALYSIS PROGRESS BAR -*/ -#evaluation-progress-bar { - margin-top: 10px; - width: 80%; -} - -/* - ANALYSIS STATUS MESSAGE (INFO/ERROR) -*/ -#status-message { - text-align: center; - color: rgb(255, 53, 53); - margin: 10px 0; -} - -/* - ANALYSIS SECONDARY MESSAGE -*/ -#secondary-message { - text-align: center; - font-size: 14px; -} - -/* - REVIEW PANEL BOTTOM TOOLBAR -*/ -#review-panel-toolbar { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; -} - -#review-panel-toolbar { - grid-row: 2 / 3; - - border-top: 2px solid var(--border-color); -} - -#review-panel-toolbar-buttons { - margin: 10px; - gap: 15px; - flex-wrap: wrap; - transition: gap 0.5s ease; -} - -#review-panel-toolbar-buttons img { - cursor: pointer; - width: 30px; -} - -#review-panel-toolbar-buttons img:hover { - opacity: 0.7; -} - -#review-panel-toolbar-buttons img:active { - opacity: 0.5; -} - -#announcement { - display: flex; - justify-content: center; - align-items: center; - gap: 10px; - - margin-bottom: 8px; - padding: 3px; - - width: 100%; - min-height: 2.5rem; - background-color: rgb(104, 139, 255); -}