feat : add accuracy calculation

This commit is contained in:
GuillaumeSD
2024-02-28 23:29:30 +01:00
parent 43017c89ee
commit a8da159870
8 changed files with 283 additions and 677 deletions

View File

@@ -3,7 +3,11 @@ import { Game } from "@/types/game";
import { Chess } from "chess.js"; import { Chess } from "chess.js";
export const getFens = (game: Chess): string[] => { 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 => { export const getGameFromPgn = (pgn: string): Chess => {
@@ -106,3 +110,9 @@ export const getEvaluationBarValue = (
return { whiteBarPercentage, label: pEval.toFixed(1) }; 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();
};

View File

@@ -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;
};

View File

@@ -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<string, LineEval> = {};
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);
};

View File

@@ -2,9 +2,11 @@ import { EngineName } from "@/types/enums";
import { import {
EvaluatePositionWithUpdateParams, EvaluatePositionWithUpdateParams,
GameEval, GameEval,
LineEval,
MoveEval, MoveEval,
} from "@/types/eval"; } from "@/types/eval";
import { parseEvaluationResults } from "./helpers/parseResults";
import { computeAccuracy } from "./helpers/accuracy";
import { getWhoIsCheckmated } from "../chess";
export abstract class UciEngine { export abstract class UciEngine {
private worker: Worker; private worker: Worker;
@@ -104,14 +106,31 @@ export abstract class UciEngine {
const moves: MoveEval[] = []; const moves: MoveEval[] = [];
for (const fen of fens) { 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); const result = await this.evaluatePosition(fen, depth);
moves.push(result); moves.push(result);
} }
const accuracy = computeAccuracy(moves);
this.ready = true; this.ready = true;
return { return {
moves, moves: moves.slice(0, -1),
accuracy: { white: 82.34, black: 67.49 }, // TODO: Calculate accuracy accuracy,
settings: { settings: {
engine: this.engineName, engine: this.engineName,
date: new Date().toISOString(), date: new Date().toISOString(),
@@ -131,7 +150,7 @@ export abstract class UciEngine {
const whiteToPlay = fen.split(" ")[1] === "w"; const whiteToPlay = fen.split(" ")[1] === "w";
return this.parseResults(results, whiteToPlay); return parseEvaluationResults(results, whiteToPlay);
} }
public async evaluatePositionWithUpdate({ public async evaluatePositionWithUpdate({
@@ -148,7 +167,7 @@ export abstract class UciEngine {
const whiteToPlay = fen.split(" ")[1] === "w"; const whiteToPlay = fen.split(" ")[1] === "w";
const onNewMessage = (messages: string[]) => { const onNewMessage = (messages: string[]) => {
const parsedResults = this.parseResults(messages, whiteToPlay); const parsedResults = parseEvaluationResults(messages, whiteToPlay);
setPartialEval(parsedResults); setPartialEval(parsedResults);
}; };
@@ -159,99 +178,4 @@ export abstract class UciEngine {
onNewMessage onNewMessage
); );
} }
private parseResults(results: string[], whiteToPlay: boolean): MoveEval {
const parsedResults: MoveEval = {
bestMove: "",
lines: [],
};
const tempResults: Record<string, LineEval> = {};
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);
}
} }

View File

@@ -5,3 +5,37 @@ export const getPaddedMonth = (month: number) => {
export const capitalize = (s: string) => { export const capitalize = (s: string) => {
return s.charAt(0).toUpperCase() + s.slice(1); 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;
};

View File

@@ -86,9 +86,10 @@ export default function ReviewPanelBody() {
<Grid item container xs={12} justifyContent="center" alignItems="center"> <Grid item container xs={12} justifyContent="center" alignItems="center">
<List sx={{ maxWidth: "95%" }}> <List sx={{ maxWidth: "95%" }}>
{engineLines.map((line) => ( {!board.isCheckmate() &&
<LineEvaluation key={line.multiPv} line={line} /> engineLines.map((line) => (
))} <LineEvaluation key={line.multiPv} line={line} />
))}
</List> </List>
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -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);
}

View File

@@ -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);
}