import { Chess } from '../../libs/chess.js'; import { EvaluationBar } from '../components/board/EvaluationBar.js'; import { chessOpeningTree } from '../data/openings.js'; import { MoveAnnotator } from './MoveAnnotator.js'; export const ClasifCss = { MOVE_THEORY: "theory-move", MOVE_BRILLIANT: "brilliant-move", MOVE_GREAT: "great-move", MOVE_PERFECT: "perfect-move", MOVE_EXCELLENT: "excellent-move", MOVE_GOOD: "good-move", MOVE_INACCURACY: "inaccuracy-move", MOVE_MISTAKE: "mistake-move", MOVE_BLUNDER: "blunder-move", MOVE_FORCED: "forced-move", MOVE_MISS: "miss-move", }; export const Classification = { BRILLIANT: { type: "brilliant", src: "/assets/classifications/brilliant.svg", class: ClasifCss.MOVE_BRILLIANT, accuracy: 1, comment: "is a brilliant move!", color: "#14e6e6" }, GREAT: { type: "great", src: "/assets/classifications/great.svg", class: ClasifCss.MOVE_GREAT, accuracy: 1, comment: "is a great move!", color: "#38a5ff" }, PERFECT: { type: "perfect", src: "/assets/classifications/perfect.svg", class: ClasifCss.MOVE_PERFECT, accuracy: 1, comment: "is perfect.", color: "#7bcc18" }, EXCELLENT: { type: "excellent", src: "/assets/classifications/excellent.svg", class: ClasifCss.MOVE_EXCELLENT, accuracy: 0.9, comment: "is an excellent move.", color: "#7bcc18" }, GOOD: { type: "good", src: "/assets/classifications/good.svg", class: ClasifCss.MOVE_GOOD, accuracy: 0.7, comment: "is a good move.", color: "#088a28" }, THEORY: { type: "theory", src: "/assets/classifications/theory.svg", class: ClasifCss.MOVE_THEORY, accuracy: 1, comment: "is theory.", color: "#e09d0d" }, INACCURACY: { type: "inaccuracy", src: "/assets/classifications/inaccuracy.svg", class: ClasifCss.MOVE_INACCURACY, accuracy: 0.4, comment: "is an inaccuracy.", color: "#ddd015" }, MISTAKE: { type: "mistake", src: "/assets/classifications/mistake.svg", class: ClasifCss.MOVE_MISTAKE, accuracy: 0.2, comment: "is a mistake.", color: "#e5820d" }, BLUNDER: { type: "blunder", src: "/assets/classifications/blunder.svg", class: ClasifCss.MOVE_BLUNDER, accuracy: 0, comment: "was a blunder!", color: "#d44242" }, FORCED: { type: "forced", src: "/assets/classifications/forced.svg", class: ClasifCss.MOVE_FORCED, accuracy: 1, comment: "was forced.", color: "#088a28" }, MISS: { type: "miss", src: "/assets/classifications/miss.svg", class: ClasifCss.MOVE_MISS, accuracy: 0, comment: "was a miss.", color: "#d44242" } }; export const CommentType = { NONE: "none", MISS: "miss", // Missed mate STALEMATE: "stalemate", WON: "won", // Won the game FORCED: "forced", GOT_MATED: "got_mated", WILL_MATE: "will_mate", STILL_WINNING: "still_winning" } export class MoveClassifier { // Classification types with no special rules static centipawnClassifications = [ Classification.PERFECT, Classification.EXCELLENT, Classification.GOOD, Classification.INACCURACY, Classification.MISTAKE, Classification.BLUNDER ]; static pieceValues = { 'p': 1, 'n': 3, 'b': 3, 'r': 5, 'q': 9, 'k': 1000 }; // Get the maximum evaluation loss for a classification to be applied // Evaluation loss threshold for excellent in a previously equal position is 30 // These numbers are from the Game Report github repo by Wintrcat static evalLossThresholds = { [Classification.PERFECT.type]: (prevEval) => Math.max(0.0001 * Math.pow(Math.abs(prevEval), 2) + 0.0236 * Math.abs(prevEval) - 3.7143, 0), [Classification.EXCELLENT.type]: (prevEval) => Math.max(0.0002 * Math.pow(Math.abs(prevEval), 2) + 0.1231 * Math.abs(prevEval) + 27.5455, 0), [Classification.GOOD.type]: (prevEval) => Math.max(0.0002 * Math.pow(Math.abs(prevEval), 2) + 0.2643 * Math.abs(prevEval) + 60.5455, 0), [Classification.INACCURACY.type]: (prevEval) => Math.max(0.0002 * Math.pow(Math.abs(prevEval), 2) + 0.3624 * Math.abs(prevEval) + 108.0909, 0), [Classification.MISTAKE.type]: (prevEval) => Math.max(0.0003 * Math.pow(Math.abs(prevEval), 2) + 0.4027 * Math.abs(prevEval) + 225.8182, 0), [Classification.BLUNDER.type]: () => Infinity }; static getAttackers(chess, square, color) { return chess.moves({ verbose: true }).filter(m => m.to === square && m.color === color); } static getDefenders(chess, square, targetColor) { // Create a copy with swapped piece color to find defenders const chessDef = new Chess(chess.fen()); const originalPiece = chessDef.get(square); chessDef.remove(square); chessDef.put({ type: originalPiece.type, color: targetColor === 'w' ? 'b' : 'w' }, square); // Set turn to target color to generate defending moves const fenParts = chessDef.fen().split(' '); fenParts[1] = targetColor; const defenderChess = new Chess(fenParts.join(' ')); return defenderChess.moves({ verbose: true }).filter(m => m.to === square && m.color === targetColor); } static isKingAdjacent(kingSquare, targetSquare) { if (!kingSquare) return false; const fileDiff = Math.abs(kingSquare.charCodeAt(0) - targetSquare.charCodeAt(0)); const rankDiff = Math.abs(parseInt(kingSquare[1]) - parseInt(targetSquare[1])); return fileDiff <= 1 && rankDiff <= 1 && (fileDiff > 0 || rankDiff > 0); } static findKingSquare(chess, color) { for (const row of chess.board()) { for (const pieceObj of row) { if (pieceObj?.type === 'k' && pieceObj.color === color) { return pieceObj.square; } } } return null; } static calculateMaterialExchange(targetValue, attackerValues, defenderValues) { let netMaterial = -targetValue; let attackerIndex = 0; let defenderIndex = 0; let isEnemyTurn = true; while (attackerIndex < attackerValues.length || defenderIndex < defenderValues.length) { if (isEnemyTurn) { if (attackerIndex >= attackerValues.length) break; netMaterial -= attackerValues[attackerIndex++]; } else { if (defenderIndex >= defenderValues.length) break; netMaterial += defenderValues[defenderIndex++]; } isEnemyTurn = !isEnemyTurn; } return netMaterial; } /** * Checks if a piece is hanging (can be captured without adequate compensation) * @param {string} fen - FEN string of the position * @param {string} square - Algebraic notation of the square to check * @returns {boolean} - True if the piece is hanging */ static isPieceHanging(fen, square) { const chess = new Chess(fen); const piece = chess.get(square); if (!piece) return false; const { type: targetPiece, color: targetColor } = piece; const enemyColor = targetColor === 'w' ? 'b' : 'w'; // Get attackers and defenders const enemyAttackers = this.getAttackers(chess, square, enemyColor); if (enemyAttackers.length === 0) return false; const friendlyDefenders = this.getDefenders(chess, square, targetColor); // Add king as potential attacker if adjacent const kingSquare = this.findKingSquare(chess, enemyColor); if (this.isKingAdjacent(kingSquare, square)) { enemyAttackers.push({ piece: 'k', color: enemyColor }); } // Quick check: more attackers than defenders if (enemyAttackers.length > friendlyDefenders.length) return true; // Calculate material exchange const targetValue = this.pieceValues[targetPiece]; const attackerValues = enemyAttackers.map(m => this.pieceValues[m.piece]).sort((a, b) => a - b); const defenderValues = friendlyDefenders.map(m => this.pieceValues[m.piece]).sort((a, b) => a - b); const netMaterial = this.calculateMaterialExchange(targetValue, attackerValues, defenderValues); return netMaterial >= 0; } /** * Finds pieces that have been sacrificed in the current position * @param {Chess} currentBoard - The current board position * @param {boolean} isBlack - Whether the current player is black * @param {Object} lastPiece - The piece that was on the destination square in the previous position (captured piece) * @param {string} fen - FEN string of the current position * @returns {Array} - Array of sacrificed pieces */ static findSacrificedPieces(currentBoard, isBlack, lastPiece, fen) { const sacrificedPieces = []; for (let row of currentBoard.board()) { for (let piece of row) { // Skip empty squares if (!piece) continue; // Skip opponent pieces if (piece.color != (isBlack ? 'w' : 'b')) continue; // Skip kings and pawns if (piece.type == "k" || piece.type == "p") continue; // Skip if the captured piece was more valuable if (lastPiece && this.pieceValues[lastPiece.type] >= this.pieceValues[piece.type]) { continue; } // Check if piece is hanging if (this.isPieceHanging(fen, piece.square)) { sacrificedPieces.push(piece); } } } return sacrificedPieces; } /** * Checks if a move is damage control rather than a genuine brilliant sacrifice * @param {Object} movedPiece - The piece that was moved * @param {Array} sacrificedPieces - Array of pieces that are now hanging * @param {string} uciMove - The UCI move string * @param {string} previousFen - FEN string of the position before the move * @returns {boolean} - True if this is damage control, not a genuine sacrifice */ static isDamageControlMove(movedPiece, sacrificedPieces, uciMove, previousFen) { if (!movedPiece || !sacrificedPieces.length) return false; const movedPieceValue = this.pieceValues[movedPiece.type]; const maxSacrificedValue = Math.max(...sacrificedPieces.map(p => this.pieceValues[p.type])); const totalSacrificedValue = sacrificedPieces.reduce((sum, p) => sum + this.pieceValues[p.type], 0); // If the moved piece is more valuable than what's being "sacrificed" if (movedPieceValue > maxSacrificedValue) { const fromSquare = uciMove.slice(0, 2); // Check if the moved piece was under attack in the previous position if (this.isPieceHanging(previousFen, fromSquare)) { return true; // Damage control: saved more valuable piece, left less valuable one } // Additional check: if multiple pieces are being "sacrificed" and their combined value // is less than the moved piece, this is likely damage control // if (sacrificedPieces.length > 1 && totalSacrificedValue < movedPieceValue * 0.8) { // return true; // } } // Check for pattern where a high-value piece moves away from a square that's under attack // and leaves behind lower-value pieces that were previously protected if (movedPieceValue >= 6) { // Rook or higher value const chess = new Chess(previousFen); const fromSquare = uciMove.slice(0, 2); // // Check if any of the sacrificed pieces were on squares that the moved piece could defend // for (const sacrificedPiece of sacrificedPieces) { // if (this.couldPieceDefend(chess, fromSquare, sacrificedPiece.square, movedPiece.type)) { // // The moved piece was potentially defending the sacrificed piece // // Moving it away and leaving the other piece hanging suggests damage control // return true; // } // } } return false; } /** * Checks if a piece could potentially defend another square * @param {Chess} chess - Chess instance * @param {string} defenderSquare - Square of the potential defender * @param {string} targetSquare - Square that might be defended * @param {string} pieceType - Type of the defending piece * @returns {boolean} - True if the piece could defend the target square */ static couldPieceDefend(chess, defenderSquare, targetSquare, pieceType) { // Simple check based on piece movement patterns const defenderFile = defenderSquare.charCodeAt(0); const defenderRank = parseInt(defenderSquare[1]); const targetFile = targetSquare.charCodeAt(0); const targetRank = parseInt(targetSquare[1]); const fileDiff = Math.abs(defenderFile - targetFile); const rankDiff = Math.abs(defenderRank - targetRank); // Fast, but not super accurate. Still helps a lot. switch (pieceType) { case 'q': // Queen can defend any square in line return fileDiff === 0 || rankDiff === 0 || fileDiff === rankDiff; case 'r': // Rook can defend same file or rank return fileDiff === 0 || rankDiff === 0; case 'b': // Bishop can defend diagonal return fileDiff === rankDiff; case 'n': // Knight has specific L-shaped moves return (fileDiff === 2 && rankDiff === 1) || (fileDiff === 1 && rankDiff === 2); case 'k': // King can defend adjacent squares return fileDiff <= 1 && rankDiff <= 1; case 'p': // Pawn can defend diagonally return fileDiff === 1 && rankDiff === 1; default: return false; } } /** * Checks if any sacrificed piece can be safely captured by the opponent * @param {string} fen - FEN string of the current position * @param {Array} sacrificedPieces - Array of pieces that are considered sacrificed * @returns {Array} - Array of pieces that can be safely captured */ static isAnySacrificeSafeToCaptureByOpponent(fen, sacrificedPieces) { const captureTestBoard = new Chess(fen); const trueSacrificedPieces = []; for (let piece of sacrificedPieces) { const attackers = captureTestBoard.moves({ verbose: true }) .filter(m => m.to === piece.square && m.color !== piece.color); if (this.canAttackerSafelyCapture(captureTestBoard, attackers, piece, sacrificedPieces)) { trueSacrificedPieces.push(piece); } } return trueSacrificedPieces; } /** * Checks if any attacker can safely capture the sacrificed piece * @param {Chess} captureTestBoard - Chess instance to test captures * @param {Array} attackers - Array of possible attacking moves * @param {Object} piece - The piece being attacked * @param {Array} sacrificedPieces - All sacrificed pieces * @returns {boolean} - True if piece can be safely captured */ static canAttackerSafelyCapture(captureTestBoard, attackers, piece, sacrificedPieces) { const promotions = ['q', 'r', 'b', 'n']; for (let attacker of attackers) { for (let promotion of promotions) { try { captureTestBoard.move({ from: attacker.from, to: piece.square, promotion: promotion }); // Check if capturing piece would be pinned/vulnerable const attackerPinned = this.isAttackerPinned(captureTestBoard, sacrificedPieces); // For high-value pieces (rook+), any safe capture is bad if (this.pieceValues[piece.type] >= 2) { if (!attackerPinned) { return true; } } // For lower value pieces, check also that there's no immediate mate else if (!attackerPinned && !captureTestBoard.moves().some(move => move.endsWith("#"))) { return true; } captureTestBoard.undo(); } catch {} } } return false; } /** * Checks if the attacker that just captured would be vulnerable * @param {Chess} captureTestBoard - Chess instance after capture * @param {Array} sacrificedPieces - All sacrificed pieces * @returns {boolean} - True if attacking piece would be pinned/vulnerable */ static isAttackerPinned(captureTestBoard, sacrificedPieces) { const maxSacrificeValue = Math.max(...sacrificedPieces.map(sack => this.pieceValues[sack.type])); for (let row of captureTestBoard.board()) { for (let enemyPiece of row) { // Skip empty squares, friendly pieces, kings and pawns if (!enemyPiece) continue; if (enemyPiece.color == captureTestBoard.turn()) continue; if (enemyPiece.type == "k" || enemyPiece.type == "p") continue; // Check if piece is hanging and worth more than the sacrifice if (this.isPieceHanging(captureTestBoard.fen(), enemyPiece.square) && this.pieceValues[enemyPiece.type] >= maxSacrificeValue) { return true; } } } return false; } /** * Checks if the move sequence matches an opening in the opening database * @param {Array} moves - Array of moves in UCI format * @returns {boolean} - True if sequence matches known opening theory */ static isInOpeningTheory(moves) { if (!moves || moves.length === 0) return false; let currentNode = chessOpeningTree; for (const move of moves) { if (currentNode[move]) { currentNode = currentNode[move]; } else { return false; } } return true; } /** * Evaluates a move to determine its classification * @param {Object} move - Current move data * @param {Object} previous - Previous move data * @param {Array} moves - Array of all moves (strings) played up to (and including) the current move * @returns {Object} - Classification object for the move */ static classifyMove(move, previous, moves) { // Prepare a default classification to return let classification = Classification.GOOD; // Get the best and second best lines const bestLine = move.lines.find(line => line.id === 1); const secondLine = move.lines.find(line => line.id === 2); const prevBestLine = previous.lines.find(line => line.id === 1); const prevSecondLine = previous.lines.find(line => line.id === 2); const isBlack = move.fen.includes(" b "); // For the fancy computer annotations move.commentData = {} move.commentType = CommentType.NONE; // If there's no first line, it's mate or draw if (!bestLine) { const board = new Chess(move.fen); if (board.isCheckmate()) { classification = Classification.PERFECT; move.commentType = CommentType.WON; move.graph = isBlack ? 0 : 100; move.win = 100; } else { move.graph = 50; move.win = 0; // You can't win/lose if it's a draw if (board.isStalemate()) { classification = Classification.MISS; move.commentType = CommentType.STALEMATE; } else { // If it's a draw by repetition, and we're not in check and were winning, it's a blunder if (board.isThreefoldRepetition() && !board.inCheck() && absEval > 0) { classification = Classification.BLUNDER; } else { classification = Classification.PERFECT; } } } move.classification = classification; return classification; } // Calculate win% for later on const win = 100 - (50 + 50 * (2 / (1 + Math.exp(-0.00368208 * (bestLine.score * (isBlack ? -1 : 1)))) - 1)); move.win = win; // Graph Evaluation (lying to the user) move.graph = 100 - EvaluationBar.scoreToPercentage(bestLine.score); // Check if the move is part of opening theory if (previous.classification == Classification.THEORY && moves.length > 0 && this.isInOpeningTheory(moves)) { classification = Classification.THEORY; move.classification = classification; move.centipawnLoss = 0; return classification; } // Since mate has a different "score" thing if (bestLine.type == "mate") { move.graph = (bestLine.score > 0) ? 0 : 100; } // No second computer line means no other line exists (the move is forced) if (!prevSecondLine) { classification = Classification.FORCED; move.classification = classification; move.commentType = CommentType.FORCED; move.centipawnLoss = 0; return classification; } // If the move has no score, it's probably because the opening book didn't work if (move.score === null) { classification = Classification.GOOD; move.classification = classification; move.centipawnLoss = 0; return classification; } // Helper function to calculate the evaluation loss difference const diff = (prevScore, currentScore) => isBlack ? prevScore - currentScore : currentScore - prevScore; // Calculate the evaluation lost as a result of this move compared to the top computer moves const matchingTopLine = move.lines.find(line => line.uciMove === move.uciMove); const lastLineEvalLoss = matchingTopLine ? diff(prevBestLine.score, matchingTopLine.score) : Infinity; const evalLoss = Math.min(diff(prevBestLine.score, bestLine.score), lastLineEvalLoss); // Used for esimated Elo calculations move.centipawnLoss = evalLoss; // We'll use absolute eval and previous absolute eval to resolve unclear mate situations const noMate = prevBestLine.type == "cp" && bestLine.type == "cp"; const absEval = bestLine.score * (isBlack ? 1 : -1); const prevAbsEval = prevBestLine.score * (isBlack ? 1 : -1); // If the move matches the top computer move already we can skip the rest if (move.uciMove === prevBestLine.uciMove) { classification = Classification.PERFECT; } else { if (noMate) { // Standard move evaluation classification = this.centipawnClassifications.find(classif => evalLoss <= this.evalLossThresholds[classif.type](0)) || classification; } // If no mate last move but you blundered a mate else if (prevBestLine.type == "cp" && bestLine.type == "mate") { move.commentData = { mateIn: absEval } if (absEval > 0) { // Comment: Nothing you can do about it. classification = Classification.PERFECT; move.commentType = CommentType.GOT_MATED; } else if (absEval >= -2) { // Comment: Missing mate in 2 is a blunder. move.commentType = CommentType.GOT_MATED; classification = Classification.BLUNDER; } else if (absEval >= -5) { move.commentType = CommentType.GOT_MATED; // Comment: Missing mate in 5 is a mistake. classification = Classification.MISTAKE; } else { move.commentType = CommentType.GOT_MATED; // Comment: Missing mate in x is a inaccuracy. (did opponent catch it) classification = Classification.INACCURACY; } } // If mate last move and there is no longer a mate else if (prevBestLine.type == "mate" && bestLine.type == "cp") { move.commentType = CommentType.MISS; if (prevAbsEval < 0 && absEval < 0) { classification = Classification.PERFECT; } else if (absEval >= 400) { classification = Classification.GOOD; } else if (absEval >= 200) { classification = Classification.MISTAKE; } else { classification = Classification.MISS; } } // If mate last move and forced mate still exists else if (prevBestLine.type == "mate" && bestLine.type == "mate") { move.commentData = { mateIn: absEval, prevMateIn: prevAbsEval } move.commentType = CommentType.WILL_MATE; if (prevAbsEval > 0) { if (absEval <= -4) { // There was a faster way to mate classification = Classification.MISTAKE; } else if (absEval < 0) { classification = Classification.BLUNDER; } else if (absEval < prevAbsEval) { classification = Classification.PERFECT; } else if (absEval <= prevAbsEval + 2) { classification = Classification.EXCELLENT; } else { classification = Classification.GOOD; } } else { if (absEval == prevAbsEval) { classification = Classification.PERFECT; } else { classification = Classification.GOOD; } } } } // Check if blunder is due to throwing away a piece if (classification === Classification.BLUNDER) { const destinationSquare = move.uciMove.slice(2, 4); // Check if the piece that just moved is now hanging if (this.isPieceHanging(move.fen, destinationSquare)) { const board = new Chess(move.fen); const piece = board.get(destinationSquare); if (piece) { // Record that this blunder threw away a piece move.commentData.thrownAwayPiece = { type: piece.type, square: destinationSquare, value: this.pieceValues[piece.type] }; } } } // The second 'great' move check if (prevSecondLine && classification === Classification.PERFECT && noMate) { // Was this move the only good move compared to the next two options? const evalDiff = Math.abs(prevBestLine.score - prevSecondLine.score); // If there was a piece hanging, the only "good" move is to capture it, so not exactly a hard to find move const wasPieceHanging = this.isPieceHanging(previous.fen, move.uciMove.slice(2, 4)); if (evalDiff > 130 && !wasPieceHanging) { classification = Classification.GREAT; } } if (!secondLine) { move.classification = classification; return classification; } const secondAbsEval = secondLine.score * (isBlack ? 1 : -1); if (classification === Classification.PERFECT || classification === Classification.GREAT || classification === Classification.EXCELLENT) { // If the position is already overwhelmingly winning (e.g. mate scenarios or huge centipawn advantage) const winningAnyways = (secondAbsEval >= 900 && bestLine.type == "cp") || (bestLine.type == "mate" && secondLine.type == "mate"); const lastBoard = new Chess(previous.fen); // Skip brilliant move detection if evaluation is negative or position is winning anyway if (absEval < -50 || winningAnyways || lastBoard.inCheck()) { // Continue to next section - great move check } else { const currentBoard = new Chess(move.fen); // Get the piece that was captured (if any) const lastPiece = lastBoard.get(move.uciMove.slice(2, 4)); // Find sacrificed pieces - friendly pieces that are now hanging let sacrificedPieces = this.findSacrificedPieces(currentBoard, isBlack, lastPiece, move.fen); // If we found sacrificed pieces, mark as brilliant and check if sacrifice is sound if (sacrificedPieces.length > 0) { classification = Classification.BRILLIANT; // Check if this is damage control rather than a genuine sacrifice const movedPiece = lastBoard.get(move.uciMove.slice(0, 2)); if (movedPiece && this.isDamageControlMove(movedPiece, sacrificedPieces, move.uciMove, previous.fen)) { // This is damage control, not a brilliant sacrifice classification = Classification.PERFECT; sacrificedPieces = []; } else { // Check if any sacrificed piece can be safely captured const piecesViablyCapturable = this.isAnySacrificeSafeToCaptureByOpponent(move.fen, sacrificedPieces); move.commentData = { piecesViablyCapturable }; // If no sacrificed piece can be safely captured, revert to PERFECT if (piecesViablyCapturable.length === 0) { classification = Classification.PERFECT; sacrificedPieces = sacrificedPieces.filter(piece => piece.square !== move.uciMove.slice(2, 4)); } } } } } // Do not allow blunder if the game is still completely winning or losing const prevWin = 100 - (50 + 50 * (2 / (1 + Math.exp(-0.00368208 * (prevBestLine.score * (!isBlack ? -1 : 1)))) - 1)); const prevOppositeWin = 100 - prevWin; if (classification == Classification.BLUNDER && (prevOppositeWin > 80 || prevOppositeWin < 20) && (win > 80 || win < 20)) { classification = Classification.INACCURACY; move.commentType = CommentType.STILL_WINNING; } // Store the classification on the move object for reference move.classification = classification; // Return the classification object return classification; } }