| import { Chess } from "../../libs/chess.js"; |
| import { GamePhase } from "../classification/GamePhase.js"; |
| import { MoveAnnotator } from "../classification/MoveAnnotator.js"; |
| import { MoveClassifier, Classification } from "../classification/MoveClassifier.js"; |
| import { Engine } from "./Engine.js"; |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| export class MoveEvaluator { |
| constructor() {} |
|
|
| static startPositionEvaluation = { |
| fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", |
| lines: [ |
| { |
| id: 1, |
| depth: 99, |
| pv: ["d2d4", "g8f6", "c2c4", "e7e6", "g1f3", "d7d5", "b1c3", "f8e7", "c1f4", "e8g8"], |
| score: 19, |
| type: "cp" |
| }, |
| { |
| id: 2, |
| depth: 99, |
| pv: ["e2e4", "e7e5", "g1f3", "b8c6", "f1b5", "g8f6", "e1g1", "f6e4", "f1e1", "e4d6"], |
| score: 18, |
| type: "cp" |
| }, |
| { |
| id: 3, |
| depth: 99, |
| pv: ["c2c4", "g8f6", "g1f3", "e7e6", "d2d4", "d7d5", "b1c3", "f8e7", "c1f4", "e8g8"], |
| score: 18, |
| type: "cp" |
| } |
| ], |
| engine: "Precomputed" |
| } |
|
|
| |
| |
| |
| |
| |
| static async tryCloudEvaluation(fen) { |
| return false; |
| |
| return $.ajax({ |
| url: "https://lichess.org/api/cloud-eval", |
| data: { fen, multiPv: 3 }, |
| method: "GET", |
| dataType: "json", |
| timeout: 2000, |
| }).then(({ depth, pvs }) => { |
| const cloudUCIFixes = { |
| e8h8: "e8g8", |
| e1h1: "e1g1", |
| e8a8: "e8c8", |
| e1a1: "e1c1", |
| }; |
| return pvs.map((pv, idx) => { |
| const moves = pv.moves.split(" "); |
| for (let i = 0; i < moves.length; i++) { |
| moves[i] = cloudUCIFixes[moves[i]] || moves[i]; |
| } |
|
|
| const uciMove = moves[0]; |
| const type = pv.cp === undefined ? "mate" : "cp"; |
| const score = pv.cp === undefined ? pv.mate : pv.cp; |
| return { id: idx + 1, depth, uciMove, score, type, pv: moves }; |
| }); |
| }) |
| .catch(() => undefined); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| static async batchEvaluateMoves(game, history, progressCallback = null, settings = {}) { |
| |
| const queue = history.map((move, i) => { |
| const moveObj = game.move(move); |
| const fen = game.fen(); |
| const uciMove = moveObj.from + moveObj.to; |
|
|
| return { move, fen, i, uciMove }; |
| }); |
|
|
| const depth = settings.engineDepth || 16; |
| const engineType = settings.engineType || 'stockfish-17-lite'; |
| const maxWorkers = navigator.hardwareConcurrency || 8; |
| const moves = new Array(history.length); |
| |
| |
| const workerPool = Array.from({ length: Math.min(maxWorkers, queue.length) }, () => new Engine({ engineType })); |
| |
| let completedMoves = 0; |
|
|
| |
| return new Promise((resolve) => { |
| |
| async function processBatch() { |
| const availableWorkers = workerPool.filter(worker => !worker.busy); |
| |
| |
| const batch = availableWorkers.map(worker => { |
| if (queue.length === 0) return null; |
| |
| const move = queue.shift(); |
| worker.busy = true; |
| |
| return { worker, move }; |
| }).filter(Boolean); |
| |
| if (batch.length === 0) { |
| if (completedMoves === history.length) { |
| |
| workerPool.forEach(worker => { |
| if (worker.worker && worker.worker.terminate) { |
| worker.worker.terminate(); |
| } |
| }); |
| progressCallback(100); |
| resolve(moves); |
| return; |
| } |
| |
| |
| setTimeout(processBatch, 100); |
| return; |
| } |
| |
| |
| await Promise.all(batch.map(async ({ worker, move }) => { |
| try { |
| |
| const cloudPromise = MoveEvaluator.tryCloudEvaluation(move.fen); |
| const localPromise = worker.evaluate(move.fen, depth); |
| |
| |
| |
| const [cloudResult, localResult] = await Promise.allSettled([ |
| |
| Promise.race([ |
| cloudPromise, |
| new Promise(resolve => setTimeout(() => resolve(undefined), 2500)) |
| ]), |
| localPromise |
| ]); |
| |
| let lines; |
| let engineName; |
| |
| |
| if (cloudResult.status === 'fulfilled' && |
| cloudResult.value && |
| cloudResult.value.length > 1 && |
| cloudResult.value[0].depth >= depth) { |
| |
| |
| worker.abort(); |
| |
| lines = cloudResult.value; |
| engineName = "Stockfish Cloud"; |
| } |
| |
| else if (localResult.status === 'fulfilled') { |
| lines = localResult.value; |
| engineName = worker.engine.name; |
| } |
| |
| else { |
| lines = []; |
| engineName = "Failed Evaluation"; |
| } |
| |
| move.lines = lines; |
| move.engine = engineName; |
| moves[move.i] = move; |
| } catch (error) { |
| console.error("Error evaluating move:", error); |
| moves[move.i] = move; |
| } finally { |
| completedMoves++; |
| worker.busy = false; |
| |
| |
| const progress = Math.round((completedMoves / history.length) * 100); |
| progressCallback(progress); |
| } |
| })); |
| |
| |
| processBatch(); |
| } |
| |
| |
| if (history.length === 0) { |
| progressCallback(100); |
| resolve([]); |
| } else { |
| processBatch(); |
| } |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| static async analyzeGame(game, progressCallback = null, settings = {}) { |
| const chess = new Chess(); |
|
|
| |
| chess.loadPgn(game.pgn); |
| const history = chess.history(); |
| chess.reset(); |
|
|
| if (history.length === 0) { |
| return { |
| white: { |
| accuracy: 0, |
| counts: {} |
| }, |
| black: { |
| accuracy: 0, |
| counts: {} |
| }, |
| moves: [] |
| } |
| } |
|
|
| const moves = await MoveEvaluator.batchEvaluateMoves(chess, history, (progress) => { |
| if (progressCallback) progressCallback(progress); |
| }, settings); |
|
|
| |
| moves[0].classification = Classification.THEORY; |
| moves[0].graph = 50; |
| for (let i = 1; i < moves.length; i++) { |
| const move = moves[i]; |
| const previous = moves[i - 1]; |
| const movesUpToCurrent = moves.slice(0, i).map(m => m.move); |
|
|
| MoveClassifier.classifyMove(move, previous, movesUpToCurrent); |
| } |
|
|
| MoveAnnotator.annotateMoves(moves, 'w'); |
| |
| |
| const whiteMoves = moves.filter(move => move.fen.includes(' b ')); |
| const blackMoves = moves.filter(move => move.fen.includes(' w ')); |
| const whiteAccuracy = whiteMoves.reduce((sum, move) => sum + move.classification.accuracy, 0) / whiteMoves.length; |
| const blackAccuracy = blackMoves.reduce((sum, move) => sum + move.classification.accuracy, 0) / blackMoves.length; |
|
|
| |
| const whiteCounts = whiteMoves.reduce((counts, move) => { |
| counts[move.classification.type] = (counts[move.classification.type] || 0) + 1; |
| return counts; |
| }, {}); |
| const blackCounts = blackMoves.reduce((counts, move) => { |
| counts[move.classification.type] = (counts[move.classification.type] || 0) + 1; |
| return counts; |
| }, {}); |
|
|
| |
| const whiteCpl = whiteMoves.reduce((sum, move) => sum + move.centipawnLoss || 0, 0) / whiteMoves.length; |
| const blackCpl = blackMoves.reduce((sum, move) => sum + move.centipawnLoss || 0, 0) / blackMoves.length; |
|
|
| const getEloFromAverageCpl = (averageCpl) => { |
| const cpl = Math.max(Math.min(averageCpl, 300), 0) / 100; |
| return -958.03125 * Math.pow(cpl, 3) + 3395.605 * Math.pow(cpl, 2) - 4216.92255 * cpl + 2574.1609; |
| } |
| |
| const getAverageCplFromElo = (elo) => { |
| return 2.09951 * Math.pow(0.998681, Math.max(Math.min(elo, 2800), 100)); |
| } |
| const getEloFromRatingAndCpl = (gameCpl, rating, backUpRating) => { |
| if (!rating || rating === 'Unrated') rating = backUpRating; |
|
|
| const eloFromCpl = getEloFromAverageCpl(gameCpl); |
| if (!rating) return eloFromCpl; |
| |
| const expectedCpl = getAverageCplFromElo(rating); |
| const cplDiff = gameCpl - expectedCpl; |
| if (cplDiff === 0) return eloFromCpl; |
|
|
| if (cplDiff > 0) { |
| return rating * Math.exp(-0.005 * cplDiff); |
| } else { |
| return rating / Math.exp(-0.005 * -cplDiff); |
| } |
| }; |
|
|
| const phases = GamePhase.getPhases(moves.map(m => m.move)); |
| const hasEndgame = phases[1] !== undefined; |
| |
| |
| const calculateAccuracy = (movesArray) => { |
| if (movesArray.length === 0) return 0; |
| return movesArray.reduce((sum, move) => sum + move.classification.accuracy, 0) / movesArray.length; |
| }; |
| |
| |
| const getClassification = (accuracy, hasBrilliant, hasGreat) => { |
| if (accuracy > 0.8 && hasBrilliant) return Classification.BRILLIANT; |
| if (accuracy > 0.8 && hasGreat) return Classification.GREAT; |
| if (accuracy > 0.8) return Classification.PERFECT; |
| if (accuracy > 0.7) return Classification.EXCELLENT; |
| if (accuracy > 0.65) return Classification.GOOD; |
| if (accuracy > 0.6) return Classification.MISTAKE; |
| return Classification.BLUNDER; |
| }; |
| |
| |
| const phaseMoves = { |
| opening: moves.slice(0, phases[0].startMove), |
| middlegame: moves.slice(phases[0].startMove, phases[1]?.startMove || moves.length), |
| ...(hasEndgame && { endgame: moves.slice(phases[1].startMove) }) |
| }; |
|
|
| |
| const phaseAnalysis = {}; |
| const phaseClassifications = { white: {}, black: {} }; |
| |
| Object.entries(phaseMoves).forEach(([phaseName, phaseMovesArray]) => { |
| const whiteMoves = phaseMovesArray.filter(m => m.fen.includes(' b ')); |
| const blackMoves = phaseMovesArray.filter(m => m.fen.includes(' w ')); |
| |
| const whiteAccuracy = calculateAccuracy(whiteMoves); |
| const blackAccuracy = calculateAccuracy(blackMoves); |
| |
| const whiteBrilliant = whiteMoves.some(m => m.classification.type === 'brilliant'); |
| const blackBrilliant = blackMoves.some(m => m.classification.type === 'brilliant'); |
| const whiteGreat = whiteMoves.some(m => m.classification.type === 'great'); |
| const blackGreat = blackMoves.some(m => m.classification.type === 'great'); |
| |
| phaseAnalysis[phaseName] = { |
| white: { accuracy: whiteAccuracy, brilliant: whiteBrilliant, great: whiteGreat }, |
| black: { accuracy: blackAccuracy, brilliant: blackBrilliant, great: blackGreat } |
| }; |
| |
| phaseClassifications.white[phaseName] = getClassification(whiteAccuracy, whiteBrilliant, whiteGreat); |
| phaseClassifications.black[phaseName] = getClassification(blackAccuracy, blackBrilliant, blackGreat); |
| }); |
|
|
| return { |
| white: { |
| accuracy: whiteAccuracy, |
| counts: whiteCounts, |
| elo: getEloFromRatingAndCpl(whiteCpl, game.white.elo, game.black.elo) |
| }, |
| black: { |
| accuracy: blackAccuracy, |
| counts: blackCounts, |
| elo: getEloFromRatingAndCpl(blackCpl, game.black.elo, game.white.elo) |
| }, |
| phaseClassifications, |
| moves: moves |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| static applyClassificationsToMoveTree(moveTree, moves, pgn) { |
| const game = new Chess(); |
| |
| game.loadPgn(pgn); |
| const history = game.history({ verbose: true }); |
| game.reset(); |
| |
| |
| for (let i = 0; i < moves.length; i++) { |
| if (i < history.length) { |
| const move = history[i]; |
| const moveId = `move_${Math.ceil((i+1)/2)}_${i % 2 === 0 ? 'w' : 'b'}_${move.san.replace('+', 'check').replace('#', 'mate')}`; |
| moveTree.updateClassification(moveId, moves[i]); |
| |
| |
| const node = moveTree.nodeMap.get(moveId); |
| if (node && moves[i].lines && moves[i].lines.length > 0) { |
| const topLine = moves[i].lines.find(line => line.id === 1); |
| if (topLine) { |
| node.evalScore = topLine.score; |
| node.evalType = topLine.type || 'cp'; |
| } |
| } |
| } |
| } |
| |
| return moveTree; |
| } |
| } |