| 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", |
| STALEMATE: "stalemate", |
| WON: "won", |
| FORCED: "forced", |
| GOT_MATED: "got_mated", |
| WILL_MATE: "will_mate", |
| STILL_WINNING: "still_winning" |
| } |
|
|
| export class MoveClassifier { |
| |
| 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 |
| }; |
|
|
| |
| |
| |
| 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) { |
| |
| 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); |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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'; |
|
|
| |
| const enemyAttackers = this.getAttackers(chess, square, enemyColor); |
| if (enemyAttackers.length === 0) return false; |
|
|
| const friendlyDefenders = this.getDefenders(chess, square, targetColor); |
|
|
| |
| const kingSquare = this.findKingSquare(chess, enemyColor); |
| if (this.isKingAdjacent(kingSquare, square)) { |
| enemyAttackers.push({ piece: 'k', color: enemyColor }); |
| } |
|
|
| |
| if (enemyAttackers.length > friendlyDefenders.length) return true; |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| static findSacrificedPieces(currentBoard, isBlack, lastPiece, fen) { |
| const sacrificedPieces = []; |
| |
| for (let row of currentBoard.board()) { |
| for (let piece of row) { |
| |
| if (!piece) continue; |
| |
| |
| if (piece.color != (isBlack ? 'w' : 'b')) continue; |
| |
| |
| if (piece.type == "k" || piece.type == "p") continue; |
| |
| |
| if (lastPiece && this.pieceValues[lastPiece.type] >= this.pieceValues[piece.type]) { |
| continue; |
| } |
| |
| |
| if (this.isPieceHanging(fen, piece.square)) { |
| sacrificedPieces.push(piece); |
| } |
| } |
| } |
| |
| return sacrificedPieces; |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 (movedPieceValue > maxSacrificedValue) { |
| const fromSquare = uciMove.slice(0, 2); |
| |
| |
| if (this.isPieceHanging(previousFen, fromSquare)) { |
| return true; |
| } |
| |
| |
| |
| |
| |
| |
| } |
| |
| |
| |
| if (movedPieceValue >= 6) { |
| const chess = new Chess(previousFen); |
| const fromSquare = uciMove.slice(0, 2); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
| |
| return false; |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| static couldPieceDefend(chess, defenderSquare, targetSquare, pieceType) { |
| |
| 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); |
| |
| |
| switch (pieceType) { |
| case 'q': |
| return fileDiff === 0 || rankDiff === 0 || fileDiff === rankDiff; |
| case 'r': |
| return fileDiff === 0 || rankDiff === 0; |
| case 'b': |
| return fileDiff === rankDiff; |
| case 'n': |
| return (fileDiff === 2 && rankDiff === 1) || (fileDiff === 1 && rankDiff === 2); |
| case 'k': |
| return fileDiff <= 1 && rankDiff <= 1; |
| case 'p': |
| return fileDiff === 1 && rankDiff === 1; |
| default: |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 |
| }); |
| |
| |
| const attackerPinned = this.isAttackerPinned(captureTestBoard, sacrificedPieces); |
| |
| |
| if (this.pieceValues[piece.type] >= 2) { |
| if (!attackerPinned) { |
| return true; |
| } |
| } |
|
|
| |
| else if (!attackerPinned && !captureTestBoard.moves().some(move => move.endsWith("#"))) { |
| return true; |
| } |
| |
| captureTestBoard.undo(); |
| } catch {} |
| } |
| } |
| |
| return false; |
| } |
| |
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| if (!enemyPiece) continue; |
| if (enemyPiece.color == captureTestBoard.turn()) continue; |
| if (enemyPiece.type == "k" || enemyPiece.type == "p") continue; |
| |
| |
| if (this.isPieceHanging(captureTestBoard.fen(), enemyPiece.square) && |
| this.pieceValues[enemyPiece.type] >= maxSacrificeValue) { |
| return true; |
| } |
| } |
| } |
| |
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| static classifyMove(move, previous, moves) { |
| |
| let classification = Classification.GOOD; |
| |
| |
| 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 "); |
|
|
| |
| move.commentData = {} |
| move.commentType = CommentType.NONE; |
|
|
| |
| 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; |
| if (board.isStalemate()) { |
| classification = Classification.MISS; |
| move.commentType = CommentType.STALEMATE; |
| } else { |
| |
| if (board.isThreefoldRepetition() && !board.inCheck() && absEval > 0) { |
| classification = Classification.BLUNDER; |
| } else { |
| classification = Classification.PERFECT; |
| } |
| } |
| } |
| move.classification = classification; |
| |
| return classification; |
| } |
|
|
| |
| const win = 100 - (50 + 50 * (2 / (1 + Math.exp(-0.00368208 * (bestLine.score * (isBlack ? -1 : 1)))) - 1)); |
| move.win = win; |
|
|
| |
| move.graph = 100 - EvaluationBar.scoreToPercentage(bestLine.score); |
| |
| |
| if (previous.classification == Classification.THEORY && moves.length > 0 && this.isInOpeningTheory(moves)) { |
| classification = Classification.THEORY; |
| move.classification = classification; |
| move.centipawnLoss = 0; |
| return classification; |
| } |
|
|
| |
| if (bestLine.type == "mate") { |
| move.graph = (bestLine.score > 0) ? 0 : 100; |
| } |
| |
| |
| if (!prevSecondLine) { |
| classification = Classification.FORCED; |
| move.classification = classification; |
| move.commentType = CommentType.FORCED; |
| move.centipawnLoss = 0; |
| return classification; |
| } |
|
|
| |
| if (move.score === null) { |
| classification = Classification.GOOD; |
| move.classification = classification; |
| move.centipawnLoss = 0; |
| return classification; |
| } |
|
|
| |
| const diff = (prevScore, currentScore) => isBlack ? prevScore - currentScore : currentScore - prevScore; |
|
|
| |
| 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); |
| |
| |
| move.centipawnLoss = evalLoss; |
|
|
| |
| const noMate = prevBestLine.type == "cp" && bestLine.type == "cp"; |
| const absEval = bestLine.score * (isBlack ? 1 : -1); |
| const prevAbsEval = prevBestLine.score * (isBlack ? 1 : -1); |
|
|
| |
| if (move.uciMove === prevBestLine.uciMove) { |
| classification = Classification.PERFECT; |
| } else { |
| if (noMate) { |
| |
| classification = this.centipawnClassifications.find(classif => evalLoss <= this.evalLossThresholds[classif.type](0)) || classification; |
| } |
|
|
| |
| else if (prevBestLine.type == "cp" && bestLine.type == "mate") { |
| move.commentData = { mateIn: absEval } |
| if (absEval > 0) { |
| |
| classification = Classification.PERFECT; |
| move.commentType = CommentType.GOT_MATED; |
| } else if (absEval >= -2) { |
| |
| move.commentType = CommentType.GOT_MATED; |
| classification = Classification.BLUNDER; |
| } else if (absEval >= -5) { |
| move.commentType = CommentType.GOT_MATED; |
| |
| classification = Classification.MISTAKE; |
| } else { |
| move.commentType = CommentType.GOT_MATED; |
| |
| classification = Classification.INACCURACY; |
| } |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| 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) { |
| |
| 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; |
| } |
| } |
| } |
| } |
|
|
| |
| if (classification === Classification.BLUNDER) { |
| const destinationSquare = move.uciMove.slice(2, 4); |
| |
| |
| if (this.isPieceHanging(move.fen, destinationSquare)) { |
| const board = new Chess(move.fen); |
| const piece = board.get(destinationSquare); |
| |
| if (piece) { |
| |
| move.commentData.thrownAwayPiece = { |
| type: piece.type, |
| square: destinationSquare, |
| value: this.pieceValues[piece.type] |
| }; |
| } |
| } |
| } |
|
|
| |
| if (prevSecondLine && classification === Classification.PERFECT && noMate) { |
| |
| const evalDiff = Math.abs(prevBestLine.score - prevSecondLine.score); |
|
|
| |
| 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) { |
| |
| const winningAnyways = (secondAbsEval >= 900 && bestLine.type == "cp") || |
| (bestLine.type == "mate" && secondLine.type == "mate"); |
| const lastBoard = new Chess(previous.fen); |
|
|
| |
| if (absEval < -50 || winningAnyways || lastBoard.inCheck()) { |
| |
| } else { |
| const currentBoard = new Chess(move.fen); |
|
|
| |
| const lastPiece = lastBoard.get(move.uciMove.slice(2, 4)); |
| |
| |
| let sacrificedPieces = this.findSacrificedPieces(currentBoard, isBlack, lastPiece, move.fen); |
|
|
| |
| if (sacrificedPieces.length > 0) { |
| classification = Classification.BRILLIANT; |
| |
| |
| const movedPiece = lastBoard.get(move.uciMove.slice(0, 2)); |
| if (movedPiece && this.isDamageControlMove(movedPiece, sacrificedPieces, move.uciMove, previous.fen)) { |
| |
| classification = Classification.PERFECT; |
| sacrificedPieces = []; |
|
|
| } else { |
| |
| const piecesViablyCapturable = this.isAnySacrificeSafeToCaptureByOpponent(move.fen, sacrificedPieces); |
|
|
| move.commentData = { piecesViablyCapturable }; |
|
|
| |
| if (piecesViablyCapturable.length === 0) { |
| classification = Classification.PERFECT; |
| sacrificedPieces = sacrificedPieces.filter(piece => piece.square !== move.uciMove.slice(2, 4)); |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| move.classification = classification; |
| |
| |
| |
| return classification; |
| } |
| } |