import { Injectable, inject } from '@angular/core'; import { Observable, forkJoin, of } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { GameApiService } from './game-api.service'; import { AnnotatedMove, AnalysisResponse, MoveQuality } from '../models/analysis.models'; const DEFAULT_DEPTH = 14; @Injectable({ providedIn: 'root' }) export class AnalysisService { private readonly gameApi = inject(GameApiService); analyzePosition(fen: string, depth = DEFAULT_DEPTH): Observable { return this.gameApi.analyzePosition({ fen, depth }); } /** * Analyse a sequence of FEN positions (one per ply) and return annotated moves. * fenHistory[0] is the starting position; fenHistory[n] is reached after move san[n-1]. */ analyzeGame( sans: string[], fenHistory: string[], depth = DEFAULT_DEPTH, ): Observable { if (sans.length === 0 || fenHistory.length < 2) { return of([]); } const requests = fenHistory.map((fen) => this.gameApi.analyzePosition({ fen, depth })); return forkJoin(requests).pipe( map((results) => this.buildAnnotations(sans, fenHistory, results)), ); } private buildAnnotations( sans: string[], fenHistory: string[], results: AnalysisResponse[], ): AnnotatedMove[] { return sans.map((san, i) => { const evalBefore = results[i]?.eval ?? null; const winChanceBefore = results[i]?.winChance ?? null; const evalAfter = results[i + 1]?.eval ?? null; const winChanceAfter = results[i + 1]?.winChance ?? null; const bestMove = results[i]?.bestMove ?? null; const quality = this.classifyQuality( evalBefore, evalAfter, winChanceBefore, winChanceAfter, san, bestMove, ); return { san, fen: fenHistory[i + 1] ?? fenHistory[i], evalBefore, evalAfter, quality, bestMove, winChanceBefore, winChanceAfter, }; }); } /** * Classify move quality based on win-chance delta. * Win-chance is from the engine's perspective (side to move before the move). * After the move the side has flipped, so we invert the after value. */ private classifyQuality( _evalBefore: number | null, _evalAfter: number | null, winChanceBefore: number | null, winChanceAfter: number | null, san: string, bestMove: string | null, ): MoveQuality | null { if (winChanceBefore === null || winChanceAfter === null) return null; // The engine expresses win chance for the mover. After our move the mover // has switched, so the opponent's win chance = winChanceAfter. Our remaining // winning chance from our own perspective is 1 - winChanceAfter. const wcAfterOurPerspective = 1 - winChanceAfter; const delta = wcAfterOurPerspective - winChanceBefore; // negative = we lost win chance if (bestMove && san === bestMove) { if (delta >= -0.01) return 'brilliant'; return 'best'; } if (delta >= -0.02) return 'good'; if (delta >= -0.05) return 'inaccuracy'; if (delta >= -0.1) return 'mistake'; return 'blunder'; } }