f9420e5848
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #11
103 lines
3.1 KiB
TypeScript
103 lines
3.1 KiB
TypeScript
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<AnalysisResponse> {
|
|
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<AnnotatedMove[]> {
|
|
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';
|
|
}
|
|
}
|