import { EngineName } from "@/types/enums"; import { EvaluateGameParams, EvaluatePositionWithUpdateParams, GameEval, PositionEval, } from "@/types/eval"; import { getResultProperty, parseEvaluationResults, } from "./helpers/parseResults"; import { computeAccuracy } from "./helpers/accuracy"; import { getIsStalemate, getWhoIsCheckmated } from "../chess"; import { getLichessEval } from "../lichess"; import { getMovesClassification } from "./helpers/moveClassification"; import { EngineWorker, WorkerJob } from "@/types/engine"; export class UciEngine { private workers: EngineWorker[]; private workerQueue: WorkerJob[] = []; private isReady = false; private engineName: EngineName; private multiPv = 3; private elo: number | undefined = undefined; private constructor(engineName: EngineName, workers: EngineWorker[]) { this.engineName = engineName; this.workers = workers; } public static async create( engineName: EngineName, workers: EngineWorker[], customEngineInit?: ( sendCommands: UciEngine["sendCommands"] ) => Promise ): Promise { const engine = new UciEngine(engineName, workers); await engine.broadcastCommands(["uci"], "uciok"); await engine.setMultiPv(engine.multiPv, true); await customEngineInit?.(engine.sendCommands.bind(engine)); for (const worker of workers) { worker.isReady = true; } engine.isReady = true; console.log(`${engineName} initialized with ${workers.length} workers`); return engine; } private acquireWorker(): EngineWorker | undefined { for (const worker of this.workers) { if (!worker.isReady) continue; worker.isReady = false; return worker; } return undefined; } private releaseWorker(worker: EngineWorker) { worker.isReady = true; const nextJob = this.workerQueue.shift(); if (nextJob) { this.sendCommands( nextJob.commands, nextJob.finalMessage, nextJob.onNewMessage ).then(nextJob.resolve); } } private async setMultiPv(multiPv: number, initCase = false) { if (!initCase) { if (multiPv === this.multiPv) return; this.throwErrorIfNotReady(); } if (multiPv < 2 || multiPv > 6) { throw new Error(`Invalid MultiPV value : ${multiPv}`); } await this.broadcastCommands( [`setoption name MultiPV value ${multiPv}`, "isready"], "readyok" ); this.multiPv = multiPv; } private async setElo(elo: number, initCase = false) { if (!initCase) { if (elo === this.elo) return; this.throwErrorIfNotReady(); } if (elo < 1320 || elo > 3190) { throw new Error(`Invalid Elo value : ${elo}`); } await this.broadcastCommands( ["setoption name UCI_LimitStrength value true", "isready"], "readyok" ); await this.broadcastCommands( [`setoption name UCI_Elo value ${elo}`, "isready"], "readyok" ); this.elo = elo; } private throwErrorIfNotReady() { if (!this.isReady) { throw new Error(`${this.engineName} is not ready`); } } public shutdown(): void { this.isReady = false; this.workerQueue = []; for (const worker of this.workers) { worker.uci("quit"); worker.terminate?.(); worker.isReady = false; } console.log(`${this.engineName} shutdown`); } public getIsReady(): boolean { return this.isReady; } public async stopSearch(): Promise { this.workerQueue = []; await this.broadcastCommands(["stop", "isready"], "readyok"); for (const worker of this.workers) { this.releaseWorker(worker); } } private async sendCommands( commands: string[], finalMessage: string, onNewMessage?: (messages: string[]) => void ): Promise { const worker = this.acquireWorker(); if (!worker) { return new Promise((resolve) => { this.workerQueue.push({ commands, finalMessage, onNewMessage, resolve, }); }); } return this.sendCommandsToWorker( worker, commands, finalMessage, onNewMessage ); } private async sendCommandsToWorker( worker: EngineWorker, commands: string[], finalMessage: string, onNewMessage?: (messages: string[]) => void ): Promise { return new Promise((resolve) => { const messages: string[] = []; worker.listen = (data) => { messages.push(data); onNewMessage?.(messages); if (data.startsWith(finalMessage)) { this.releaseWorker(worker); resolve(messages); } }; for (const command of commands) { worker.uci(command); } }); } private async broadcastCommands( commands: string[], finalMessage: string, onNewMessage?: (messages: string[]) => void ): Promise { await Promise.all( this.workers.map((worker) => this.sendCommandsToWorker(worker, commands, finalMessage, onNewMessage) ) ); } public async evaluateGame({ fens, uciMoves, depth = 16, multiPv = this.multiPv, setEvaluationProgress, }: EvaluateGameParams): Promise { this.throwErrorIfNotReady(); setEvaluationProgress?.(1); await this.setMultiPv(multiPv); this.isReady = false; await this.broadcastCommands(["ucinewgame", "isready"], "readyok"); const positions: PositionEval[] = new Array(fens.length); let completed = 0; const updateEval = (index: number, positionEval: PositionEval) => { completed++; positions[index] = positionEval; const progress = completed / fens.length; setEvaluationProgress?.(99 - Math.exp(-4 * progress) * 99); }; await Promise.all( fens.map(async (fen, i) => { const whoIsCheckmated = getWhoIsCheckmated(fen); if (whoIsCheckmated) { updateEval(i, { lines: [ { pv: [], depth: 0, multiPv: 1, mate: whoIsCheckmated === "w" ? -1 : 1, }, ], }); return; } const isStalemate = getIsStalemate(fen); if (isStalemate) { updateEval(i, { lines: [ { pv: [], depth: 0, multiPv: 1, cp: 0, }, ], }); return; } const result = await this.evaluatePosition(fen, depth); updateEval(i, result); }) ); const positionsWithClassification = getMovesClassification( positions, uciMoves, fens ); const accuracy = computeAccuracy(positions); this.isReady = true; return { positions: positionsWithClassification, accuracy, settings: { engine: this.engineName, date: new Date().toISOString(), depth, multiPv, }, }; } private async evaluatePosition( fen: string, depth = 16 ): Promise { if (this.workers.length < 2) { const lichessEval = await getLichessEval(fen, this.multiPv); if ( lichessEval.lines.length >= this.multiPv && lichessEval.lines[0].depth >= depth ) { return lichessEval; } } const results = await this.sendCommands( [`position fen ${fen}`, `go depth ${depth}`], "bestmove" ); const whiteToPlay = fen.split(" ")[1] === "w"; return parseEvaluationResults(results, whiteToPlay); } public async evaluatePositionWithUpdate({ fen, depth = 16, multiPv = this.multiPv, setPartialEval, }: EvaluatePositionWithUpdateParams): Promise { this.throwErrorIfNotReady(); const lichessEvalPromise = getLichessEval(fen, multiPv); await this.stopSearch(); await this.setMultiPv(multiPv); const whiteToPlay = fen.split(" ")[1] === "w"; const onNewMessage = (messages: string[]) => { if (!setPartialEval) return; const parsedResults = parseEvaluationResults(messages, whiteToPlay); setPartialEval(parsedResults); }; console.log(`Evaluating position: ${fen}`); const lichessEval = await lichessEvalPromise; if ( lichessEval.lines.length >= multiPv && lichessEval.lines[0].depth >= depth ) { setPartialEval?.(lichessEval); return lichessEval; } const results = await this.sendCommands( [`position fen ${fen}`, `go depth ${depth}`], "bestmove", onNewMessage ); return parseEvaluationResults(results, whiteToPlay); } public async getEngineNextMove( fen: string, elo: number, depth = 16 ): Promise { this.throwErrorIfNotReady(); await this.setElo(elo); console.log(`Evaluating position: ${fen}`); const results = await this.sendCommands( [`position fen ${fen}`, `go depth ${depth}`], "bestmove" ); const moveResult = results.find((result) => result.startsWith("bestmove")); const move = getResultProperty(moveResult ?? "", "bestmove"); if (!move) { throw new Error("No move found"); } return move === "(none)" ? undefined : move; } }