Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/components/board/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 6 additions & 4 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ export const MAIN_THEME_COLOR = "#3B9AC6";
export const LINEAR_PROGRESS_BAR_COLOR = "#3B9AC6";

export const CLASSIFICATION_COLORS: Record<MoveClassification, string> = {
[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;
Expand Down
22 changes: 14 additions & 8 deletions src/lib/chess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
254 changes: 54 additions & 200 deletions src/lib/engine/helpers/moveClassification.ts
Original file line number Diff line number Diff line change
@@ -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[],
Expand All @@ -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;
Expand All @@ -29,231 +38,76 @@ 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,
moveClassification: MoveClassification.Forced,
};
}

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;
};
2 changes: 1 addition & 1 deletion src/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading