diff --git a/src/components/board/index.tsx b/src/components/board/index.tsx index 40cf5ca5..e259b601 100644 --- a/src/components/board/index.tsx +++ b/src/components/board/index.tsx @@ -214,10 +214,11 @@ export default function Board({ if ( bestMove && showBestMoveArrow && + moveClassification !== MoveClassification.Splendid && + moveClassification !== MoveClassification.Perfect && moveClassification !== MoveClassification.Best && moveClassification !== MoveClassification.Opening && - moveClassification !== MoveClassification.Forced && - moveClassification !== MoveClassification.Perfect + moveClassification !== MoveClassification.Forced ) { const bestMoveArrow = [ bestMove.slice(0, 2), diff --git a/src/constants.ts b/src/constants.ts index 3ecc9125..63edb047 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,16 +4,18 @@ export const MAIN_THEME_COLOR = "#3B9AC6"; export const LINEAR_PROGRESS_BAR_COLOR = "#3B9AC6"; export const CLASSIFICATION_COLORS: Record = { - [MoveClassification.Opening]: "#dbac86", - [MoveClassification.Forced]: "#dbac86", - [MoveClassification.Splendid]: "#19d4af", - [MoveClassification.Perfect]: "#3894eb", + // Standard classifications: [MoveClassification.Best]: "#22ac38", [MoveClassification.Excellent]: "#22ac38", [MoveClassification.Okay]: "#74b038", [MoveClassification.Inaccuracy]: "#f2be1f", [MoveClassification.Mistake]: "#e69f00", [MoveClassification.Blunder]: "#df5353", + // Special classifications: + [MoveClassification.Splendid]: "#19d4af", + [MoveClassification.Perfect]: "#3894eb", + [MoveClassification.Opening]: "#dbac86", + [MoveClassification.Forced]: "#dbac86", }; export const DEFAULT_ENGINE: EngineName = EngineName.Stockfish18Lite; diff --git a/src/lib/chess.ts b/src/lib/chess.ts index af3f0f5f..88165682 100644 --- a/src/lib/chess.ts +++ b/src/lib/chess.ts @@ -176,19 +176,25 @@ export const uciMoveParams = ( promotion: uciMove.slice(4, 5) || undefined, }); -export const isSimplePieceRecapture = ( +// Also counting pieces of higher value that can be taken with a lower value piece as hanging +// e.g. a rook threatened by a pawn is considered hanging +export const isHangingPieceCapture = ( fen: string, - uciMoves: [string, string] + playedMove: string ): boolean => { - const game = new Chess(fen); - const moves = uciMoves.map((uciMove) => uciMoveParams(uciMove)); + const chess = new Chess(fen); + const move = chess.move(uciMoveParams(playedMove)); + + if (!move.captured) return false; + + const capturedValue = getPieceValue(move.captured); + const capturingValue = getPieceValue(move.piece); - if (moves[0].to !== moves[1].to) return false; + if (capturingValue < capturedValue) return true; - const piece = game.get(moves[0].to); - if (piece) return true; + const isDefended = chess.moves({ verbose: true }).some((m) => m.to === move.to); - return false; + return !isDefended; }; export const getIsPieceSacrifice = ( diff --git a/src/lib/engine/helpers/moveClassification.ts b/src/lib/engine/helpers/moveClassification.ts index a36cf978..54547357 100644 --- a/src/lib/engine/helpers/moveClassification.ts +++ b/src/lib/engine/helpers/moveClassification.ts @@ -1,11 +1,18 @@ -import { LineEval, PositionEval } from "@/types/eval"; +import { PositionEval } from "@/types/eval"; import { getLineWinPercentage, getPositionWinPercentage, } from "./winPercentage"; import { MoveClassification } from "@/types/enums"; import { openings } from "@/data/openings"; -import { getIsPieceSacrifice, isSimplePieceRecapture } from "@/lib/chess"; +import { getIsPieceSacrifice, isHangingPieceCapture } from "@/lib/chess"; + +// Thresholds for move quality classification (in win percentage points) +// Chess.com seems to adjust these dynamically based on the player's strength... Maybe we could do something similar in the future +const BLUNDER_THRESHOLD = -20; +const MISTAKE_THRESHOLD = -10; +const INACCURACY_THRESHOLD = -5; +const EXCELLENT_THRESHOLD = -2; export const getMovesClassification = ( rawPositions: PositionEval[], @@ -19,6 +26,8 @@ export const getMovesClassification = ( if (index === 0) return rawPosition; const currentFen = fens[index].split(" ")[0]; + + // Book move: known opening position const opening = openings.find((opening) => opening.fen === currentFen); if (opening) { currentOpening = opening.name; @@ -29,9 +38,12 @@ export const getMovesClassification = ( }; } + const playedMove = uciMoves[index - 1]; const prevPosition = rawPositions[index - 1]; + const alternativeLine = prevPosition.lines.find((line) => line.pv[0] !== playedMove); - if (prevPosition.lines.length === 1) { + // Forced move: only one legal response available + if (!alternativeLine) { return { ...rawPosition, opening: currentOpening, @@ -39,221 +51,63 @@ export const getMovesClassification = ( }; } - const playedMove = uciMoves[index - 1]; - - const lastPositionAlternativeLine: LineEval | undefined = - prevPosition.lines.filter((line) => line.pv[0] !== playedMove)?.[0]; - const lastPositionAlternativeLineWinPercentage = lastPositionAlternativeLine - ? getLineWinPercentage(lastPositionAlternativeLine) - : undefined; - - const bestLinePvToPlay = rawPosition.lines[0].pv; - - const lastPositionWinPercentage = positionsWinPercentage[index - 1]; - const positionWinPercentage = positionsWinPercentage[index]; - const sideToMove = fens[index - 1].split(" ")[1]; const isWhiteMove = sideToMove === "w"; - - if ( - isSplendidMove( - lastPositionWinPercentage, - positionWinPercentage, - isWhiteMove, - playedMove, - bestLinePvToPlay, - fens[index - 1], - lastPositionAlternativeLineWinPercentage - ) - ) { - return { - ...rawPosition, - opening: currentOpening, - moveClassification: MoveClassification.Splendid, - }; - } - - const fenTwoMovesAgo = index > 1 ? fens[index - 2] : null; - const uciNextTwoMoves: [string, string] | null = - index > 1 ? [uciMoves[index - 2], uciMoves[index - 1]] : null; - - if ( - isPerfectMove( - lastPositionWinPercentage, - positionWinPercentage, - isWhiteMove, - lastPositionAlternativeLineWinPercentage, - fenTwoMovesAgo, - uciNextTwoMoves - ) - ) { - return { - ...rawPosition, - opening: currentOpening, - moveClassification: MoveClassification.Perfect, - }; - } + const lastWinPct = positionsWinPercentage[index - 1]; + const currentWinPct = positionsWinPercentage[index]; + const winPctChange = (currentWinPct - lastWinPct) * (isWhiteMove ? 1 : -1); + const alternativeWinPct = getLineWinPercentage(alternativeLine); + const alternativeWinPctChange = (alternativeWinPct - lastWinPct) * (isWhiteMove ? 1 : -1); if (playedMove === prevPosition.bestMove) { + const alternativesCollapseSignificantly = alternativeWinPctChange < winPctChange - 10; + const hangingPieceCapture = isHangingPieceCapture(fens[index - 1], playedMove); + // Sometimes close to checkmate winPctChange becomes a bad metric, so we also use: + const alternativeIsUselessSacrifice = + getIsPieceSacrifice(fens[index - 1], alternativeLine.pv[0], alternativeLine.pv.slice(1)) && + alternativeWinPctChange < BLUNDER_THRESHOLD; + + // Best: The move played is the engine's top choice, but not necessarily a brilliant move + if (hangingPieceCapture || !alternativesCollapseSignificantly || alternativeIsUselessSacrifice) { + return { + ...rawPosition, + opening: currentOpening, + moveClassification: MoveClassification.Best, + }; + } + + // Brilliant: The move played involves a piece sacrifice and is the only good move (alternatives collapse significantly) + if (getIsPieceSacrifice(fens[index - 1], playedMove, rawPosition.lines[0].pv)) { + return { + ...rawPosition, + opening: currentOpening, + moveClassification: MoveClassification.Splendid, + }; + } + + // Great: The move played is the only good move (alternatives collapse significantly) return { ...rawPosition, opening: currentOpening, - moveClassification: MoveClassification.Best, + moveClassification: MoveClassification.Perfect, }; } - const moveClassification = getMoveBasicClassification( - lastPositionWinPercentage, - positionWinPercentage, - isWhiteMove - ); - + // Standard classifications return { ...rawPosition, opening: currentOpening, - moveClassification, + moveClassification: classifyByWinPctChange(winPctChange), }; }); return positions; }; -const getMoveBasicClassification = ( - lastPositionWinPercentage: number, - positionWinPercentage: number, - isWhiteMove: boolean -): MoveClassification => { - const winPercentageDiff = - (positionWinPercentage - lastPositionWinPercentage) * - (isWhiteMove ? 1 : -1); - - if (winPercentageDiff < -20) return MoveClassification.Blunder; - if (winPercentageDiff < -10) return MoveClassification.Mistake; - if (winPercentageDiff < -5) return MoveClassification.Inaccuracy; - if (winPercentageDiff < -2) return MoveClassification.Okay; +const classifyByWinPctChange = (winPctChange: number): MoveClassification => { + if (winPctChange < BLUNDER_THRESHOLD) return MoveClassification.Blunder; + if (winPctChange < MISTAKE_THRESHOLD) return MoveClassification.Mistake; + if (winPctChange < INACCURACY_THRESHOLD) return MoveClassification.Inaccuracy; + if (winPctChange < EXCELLENT_THRESHOLD) return MoveClassification.Okay; return MoveClassification.Excellent; }; - -const isSplendidMove = ( - lastPositionWinPercentage: number, - positionWinPercentage: number, - isWhiteMove: boolean, - playedMove: string, - bestLinePvToPlay: string[], - fen: string, - lastPositionAlternativeLineWinPercentage: number | undefined -): boolean => { - if (!lastPositionAlternativeLineWinPercentage) return false; - - const winPercentageDiff = - (positionWinPercentage - lastPositionWinPercentage) * - (isWhiteMove ? 1 : -1); - if (winPercentageDiff < -2) return false; - - const isPieceSacrifice = getIsPieceSacrifice( - fen, - playedMove, - bestLinePvToPlay - ); - if (!isPieceSacrifice) return false; - - if ( - isLosingOrAlternateCompletelyWinning( - positionWinPercentage, - lastPositionAlternativeLineWinPercentage, - isWhiteMove - ) - ) { - return false; - } - - return true; -}; - -const isLosingOrAlternateCompletelyWinning = ( - positionWinPercentage: number, - lastPositionAlternativeLineWinPercentage: number, - isWhiteMove: boolean -): boolean => { - const isLosing = isWhiteMove - ? positionWinPercentage < 50 - : positionWinPercentage > 50; - const isAlternateCompletelyWinning = isWhiteMove - ? lastPositionAlternativeLineWinPercentage > 97 - : lastPositionAlternativeLineWinPercentage < 3; - - return isLosing || isAlternateCompletelyWinning; -}; - -const isPerfectMove = ( - lastPositionWinPercentage: number, - positionWinPercentage: number, - isWhiteMove: boolean, - lastPositionAlternativeLineWinPercentage: number | undefined, - fenTwoMovesAgo: string | null, - uciMoves: [string, string] | null -): boolean => { - if (!lastPositionAlternativeLineWinPercentage) return false; - - const winPercentageDiff = - (positionWinPercentage - lastPositionWinPercentage) * - (isWhiteMove ? 1 : -1); - if (winPercentageDiff < -2) return false; - - if ( - fenTwoMovesAgo && - uciMoves && - isSimplePieceRecapture(fenTwoMovesAgo, uciMoves) - ) - return false; - - if ( - isLosingOrAlternateCompletelyWinning( - positionWinPercentage, - lastPositionAlternativeLineWinPercentage, - isWhiteMove - ) - ) { - return false; - } - - const hasChangedGameOutcome = getHasChangedGameOutcome( - lastPositionWinPercentage, - positionWinPercentage, - isWhiteMove - ); - - const isTheOnlyGoodMove = getIsTheOnlyGoodMove( - positionWinPercentage, - lastPositionAlternativeLineWinPercentage, - isWhiteMove - ); - - return hasChangedGameOutcome || isTheOnlyGoodMove; -}; - -const getHasChangedGameOutcome = ( - lastPositionWinPercentage: number, - positionWinPercentage: number, - isWhiteMove: boolean -): boolean => { - const winPercentageDiff = - (positionWinPercentage - lastPositionWinPercentage) * - (isWhiteMove ? 1 : -1); - return ( - winPercentageDiff > 10 && - ((lastPositionWinPercentage < 50 && positionWinPercentage > 50) || - (lastPositionWinPercentage > 50 && positionWinPercentage < 50)) - ); -}; - -const getIsTheOnlyGoodMove = ( - positionWinPercentage: number, - lastPositionAlternativeLineWinPercentage: number, - isWhiteMove: boolean -): boolean => { - const winPercentageDiff = - (positionWinPercentage - lastPositionAlternativeLineWinPercentage) * - (isWhiteMove ? 1 : -1); - return winPercentageDiff > 10; -}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 0b1dff5b..9c1abb09 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -59,7 +59,7 @@ export default function GameAnalysis() { style={{ maxWidth: "1200px", }} - rowGap={2} + rowGap={1.5} height={{ xs: tab === 1 ? "40rem" : "auto", lg: "calc(95vh - 60px)" }} display="flex" flexDirection="column" diff --git a/src/sections/analysis/panelBody/analysisTab/moveInfo.tsx b/src/sections/analysis/panelBody/analysisTab/moveInfo.tsx index 4e84e516..fb695e25 100644 --- a/src/sections/analysis/panelBody/analysisTab/moveInfo.tsx +++ b/src/sections/analysis/panelBody/analysisTab/moveInfo.tsx @@ -44,11 +44,11 @@ export default function MoveInfo() { const moveClassification = position.eval?.moveClassification; const showBestMoveLabel = + moveClassification !== MoveClassification.Splendid && + moveClassification !== MoveClassification.Perfect && moveClassification !== MoveClassification.Best && moveClassification !== MoveClassification.Opening && - moveClassification !== MoveClassification.Forced && - moveClassification !== MoveClassification.Splendid && - moveClassification !== MoveClassification.Perfect; + moveClassification !== MoveClassification.Forced; return ( = { - [MoveClassification.Opening]: "an opening move", - [MoveClassification.Forced]: "forced", - [MoveClassification.Splendid]: "splendid !!", - [MoveClassification.Perfect]: "the only good move !", + // Standard classifications: [MoveClassification.Best]: "the best move", [MoveClassification.Excellent]: "excellent", [MoveClassification.Okay]: "an okay move", [MoveClassification.Inaccuracy]: "an inaccuracy", [MoveClassification.Mistake]: "a mistake", [MoveClassification.Blunder]: "a blunder", + // Special classifications: + [MoveClassification.Splendid]: "splendid !!", + [MoveClassification.Perfect]: "the only good move !", + [MoveClassification.Opening]: "an opening move", + [MoveClassification.Forced]: "forced", }; diff --git a/src/sections/analysis/panelBody/graphTab/index.tsx b/src/sections/analysis/panelBody/graphTab/index.tsx index bac5f8cf..ea653a10 100644 --- a/src/sections/analysis/panelBody/graphTab/index.tsx +++ b/src/sections/analysis/panelBody/graphTab/index.tsx @@ -67,8 +67,8 @@ export default function GraphTab(props: GridProps) { [ MoveClassification.Splendid, MoveClassification.Perfect, - MoveClassification.Blunder, MoveClassification.Mistake, + MoveClassification.Blunder, ].includes(moveClass) || (moveClass === MoveClassification.Best && bestDotIndices.has(payload.moveNb)) diff --git a/src/types/enums.ts b/src/types/enums.ts index 3f9f76d9..fae6ae20 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -19,16 +19,18 @@ export enum EngineName { } export enum MoveClassification { - Blunder = "blunder", - Mistake = "mistake", - Inaccuracy = "inaccuracy", - Okay = "okay", - Excellent = "excellent", + // Standard classifications: Best = "best", - Forced = "forced", - Opening = "opening", - Perfect = "perfect", + Excellent = "excellent", + Okay = "okay", + Inaccuracy = "inaccuracy", + Mistake = "mistake", + Blunder = "blunder", + // Special classifications: Splendid = "splendid", + Perfect = "perfect", + Opening = "opening", + Forced = "forced", } export enum Color {