feat: NCWF-5/6/7/8/9 chess analysis page and engine integration (#11)
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user