use crate::board::Board;
use crate::classifications::{
self,
accuracy::PlayerAccuracy,
expected_points::get_expected_points_loss,
types::{ClassificationContext, ClassificationKind, Evaluation, MoveClassification},
};
use crate::fen::board_to_fen;
use crate::game::Game;
use crate::movegen::{apply_move, generate_legal_moves, is_in_check};
use crate::opening::{BUILTIN_OPENINGS_JSON, OpeningBook};
use crate::pgn::{move_to_san, pgn_moves_to_uci};
use crate::types::{ChessError, ChessResult, Color};
use crate::uci::{Score, SearchConfig, UciEngine};
use cli_table::{format::Justify, Cell, Style, Table};
use serde::Serialize;
fn classification_annotation(kind: &ClassificationKind) -> &'static str {
match kind {
ClassificationKind::Brilliant => "!!",
ClassificationKind::Great => "!",
ClassificationKind::Risky => "!?",
ClassificationKind::Inaccuracy => "?!",
ClassificationKind::Miss | ClassificationKind::Mistake => "?",
ClassificationKind::Blunder => "??",
_ => "",
}
}
fn normalize_eval_to_white(eval: Evaluation, side_to_move: Color) -> Evaluation {
if side_to_move == Color::Black {
match eval {
Evaluation::Centipawn(v) => Evaluation::Centipawn(-v),
Evaluation::Mate(v) => Evaluation::Mate(-v),
}
} else {
eval
}
}
fn score_to_eval(score: &Score) -> Evaluation {
match score {
Score::Centipawns(cp) => Evaluation::Centipawn(*cp),
Score::Mate(m) => Evaluation::Mate(*m),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
White,
Black,
}
#[derive(Debug, Clone)]
pub struct AnalyzerConfig {
pub search: SearchConfig,
pub read_timeout_ms: Option<u64>,
}
impl AnalyzerConfig {
pub fn depth(d: u32) -> Self {
Self {
search: SearchConfig::depth(d),
read_timeout_ms: Some(60_000),
}
}
pub fn movetime(ms: u64) -> Self {
Self {
search: SearchConfig::movetime(ms),
read_timeout_ms: Some(60_000),
}
}
pub fn with_timeout(mut self, ms: u64) -> Self {
self.read_timeout_ms = Some(ms);
self
}
}
impl Default for AnalyzerConfig {
fn default() -> Self {
Self {
search: SearchConfig::depth(16),
read_timeout_ms: Some(60_000),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PlayerSummary {
pub accuracy: f64,
pub avg_point_loss: f64,
pub brilliant: u32,
pub great: u32,
pub best: u32,
pub excellent: u32,
pub good: u32,
pub okay: u32,
pub inaccuracy: u32,
pub miss: u32,
pub mistake: u32,
pub blunder: u32,
pub forced: u32,
pub book: u32,
pub risky: u32,
}
impl PlayerSummary {
fn from_classifications(moves: &[MoveClassification]) -> Self {
let mut s = Self {
accuracy: 0.0,
avg_point_loss: 0.0,
brilliant: 0,
great: 0,
best: 0,
excellent: 0,
good: 0,
okay: 0,
inaccuracy: 0,
miss: 0,
mistake: 0,
blunder: 0,
forced: 0,
book: 0,
risky: 0,
};
for mv in moves {
match mv.kind {
ClassificationKind::Brilliant => s.brilliant += 1,
ClassificationKind::Great => s.great += 1,
ClassificationKind::Best => s.best += 1,
ClassificationKind::Excellent => s.excellent += 1,
ClassificationKind::Good => s.good += 1,
ClassificationKind::Okay => s.okay += 1,
ClassificationKind::Inaccuracy => s.inaccuracy += 1,
ClassificationKind::Miss => s.miss += 1,
ClassificationKind::Mistake => s.mistake += 1,
ClassificationKind::Blunder => s.blunder += 1,
ClassificationKind::Forced => s.forced += 1,
ClassificationKind::Book => s.book += 1,
ClassificationKind::Risky => s.risky += 1,
}
}
let losses: Vec<f64> = moves.iter().map(|m| m.point_loss).collect();
let acc = PlayerAccuracy::from_point_losses(&losses);
s.accuracy = acc.average;
s.avg_point_loss = if losses.is_empty() {
0.0
} else {
losses.iter().sum::<f64>() / losses.len() as f64
};
s
}
}
#[derive(Serialize)]
#[serde(tag = "type", content = "value", rename_all = "lowercase")]
enum EvalJson {
Centipawn(i32),
Mate(i32),
}
impl From<&Evaluation> for EvalJson {
fn from(e: &Evaluation) -> Self {
match e {
Evaluation::Centipawn(v) => EvalJson::Centipawn(*v),
Evaluation::Mate(v) => EvalJson::Mate(*v),
}
}
}
#[derive(Serialize)]
struct MoveJson<'a> {
move_number: u32,
color: &'static str,
san: &'a str,
uci: &'a str,
best_move: &'a str,
classification: String,
point_loss: f64,
accuracy: f64,
eval_before: EvalJson,
eval_after: EvalJson,
}
#[derive(Debug)]
pub struct GameReport {
pub moves: Vec<MoveClassification>,
pub white: PlayerSummary,
pub black: PlayerSummary,
}
impl GameReport {
pub fn to_pgn(&self) -> String {
let mut pgn = String::new();
let mut last_num = 0u32;
for mc in &self.moves {
let ann = classification_annotation(&mc.kind);
let annotated = format!("{}{}", mc.san, ann);
match mc.color {
Color::White => {
if !pgn.is_empty() {
pgn.push(' ');
}
pgn.push_str(&format!("{}. {}", mc.move_number, annotated));
}
Color::Black => {
if mc.move_number != last_num {
if !pgn.is_empty() {
pgn.push(' ');
}
pgn.push_str(&format!("{}... {}", mc.move_number, annotated));
} else {
pgn.push(' ');
pgn.push_str(&annotated);
}
}
}
last_num = mc.move_number;
}
pgn
}
pub fn to_json(&self) -> String {
#[derive(Serialize)]
struct Report<'a> {
moves: Vec<MoveJson<'a>>,
white: &'a PlayerSummary,
black: &'a PlayerSummary,
}
let moves = self
.moves
.iter()
.map(|mc| MoveJson {
move_number: mc.move_number,
color: match mc.color {
Color::White => "white",
Color::Black => "black",
},
san: &mc.san,
uci: &mc.played_move,
best_move: &mc.best_move,
classification: mc.kind.to_string(),
point_loss: mc.point_loss,
accuracy: mc.accuracy,
eval_before: EvalJson::from(&mc.eval_before),
eval_after: EvalJson::from(&mc.eval_after),
})
.collect();
let report = Report {
moves,
white: &self.white,
black: &self.black,
};
serde_json::to_string(&report).unwrap_or_else(|_| "{}".to_owned())
}
pub fn to_table(&self) -> String {
let rows: Vec<Vec<cli_table::CellStruct>> = self
.moves
.iter()
.map(|mc| {
let num_str = match mc.color {
Color::White => format!("{}.", mc.move_number),
Color::Black => format!("{}...", mc.move_number),
};
let color_str = match mc.color {
Color::White => "White",
Color::Black => "Black",
};
let ann = classification_annotation(&mc.kind);
let san_ann = format!("{}{}", mc.san, ann);
vec![
num_str.cell(),
color_str.cell(),
san_ann.cell(),
mc.kind.to_string().cell(),
format!("{:.4}", mc.point_loss).cell().justify(Justify::Right),
format!("{:.1}%", mc.accuracy).cell().justify(Justify::Right),
]
})
.collect();
let table = rows
.table()
.title(vec![
"#".cell().bold(true),
"Color".cell().bold(true),
"Move".cell().bold(true),
"Classification".cell().bold(true),
"Loss".cell().bold(true),
"Accuracy".cell().bold(true),
])
.bold(true);
let mut output = table.display().map(|d| d.to_string()).unwrap_or_default();
output.push('\n');
output.push_str(&format!(
"White: {:.1}% accuracy · Black: {:.1}% accuracy\n",
self.white.accuracy, self.black.accuracy,
));
output
}
}
type ProgressFn<'a> = Box<dyn Fn(usize, usize, &str) + 'a>;
pub struct PgnAnalysisBuilder<'a, 'e> {
analyzer: &'a mut MoveAnalyzer<'e>,
pgn: String,
progress: Option<ProgressFn<'a>>,
min_classification: Option<ClassificationKind>,
side: Option<Side>,
}
impl<'a, 'e> PgnAnalysisBuilder<'a, 'e> {
pub fn on_progress<F>(mut self, f: F) -> Self
where
F: Fn(usize, usize, &str) + 'a,
{
self.progress = Some(Box::new(f));
self
}
pub fn min_classification(mut self, kind: ClassificationKind) -> Self {
self.min_classification = Some(kind);
self
}
pub fn side(mut self, s: Side) -> Self {
self.side = Some(s);
self
}
pub fn run(self) -> ChessResult<GameReport> {
let PgnAnalysisBuilder {
analyzer,
pgn,
progress,
min_classification,
side,
} = self;
let start_fen = board_to_fen(&crate::board::Board::starting_position());
let (_, san_moves) = crate::pgn::parse_pgn(&pgn)
.map_err(|e| ChessError::new(format!("PGN parse error: {e}")))?;
let uci_moves = pgn_moves_to_uci(&start_fen, &san_moves)?;
let total = uci_moves.len();
analyzer.engine.new_game().map_err(ChessError::from)?;
let mut game = Game::new();
let mut all_moves: Vec<MoveClassification> = Vec::new();
let mut white_classified: Vec<MoveClassification> = Vec::new();
let mut black_classified: Vec<MoveClassification> = Vec::new();
for (i, uci) in uci_moves.iter().enumerate() {
if let Some(ref cb) = progress {
cb(i + 1, total, uci);
}
let is_white_move = i % 2 == 0;
let skip = match side {
Some(Side::White) => !is_white_move,
Some(Side::Black) => is_white_move,
None => false,
};
if !skip {
let move_number = (i / 2) as u32 + 1;
let mc = analyzer.classify_board(game.current_board(), uci, move_number)?;
let include = match &min_classification {
Some(threshold) => mc.kind >= *threshold,
None => true,
};
if include {
if is_white_move {
white_classified.push(mc.clone());
} else {
black_classified.push(mc.clone());
}
all_moves.push(mc);
}
}
game.do_move(uci)?;
}
Ok(GameReport {
white: PlayerSummary::from_classifications(&white_classified),
black: PlayerSummary::from_classifications(&black_classified),
moves: all_moves,
})
}
}
pub struct MoveAnalyzer<'e> {
engine: &'e mut UciEngine,
config: AnalyzerConfig,
game: Game,
opening_book: OpeningBook,
}
impl<'e> MoveAnalyzer<'e> {
pub fn new(engine: &'e mut UciEngine, config: AnalyzerConfig) -> Self {
let opening_book =
OpeningBook::from_json(BUILTIN_OPENINGS_JSON).unwrap_or_else(|_| OpeningBook::empty());
Self {
engine,
config,
game: Game::new(),
opening_book,
}
}
pub fn from_game(game: &Game, engine: &'e mut UciEngine, config: AnalyzerConfig) -> Self {
let opening_book =
OpeningBook::from_json(BUILTIN_OPENINGS_JSON).unwrap_or_else(|_| OpeningBook::empty());
let internal = Game::from_fen(&game.get_fen()).unwrap_or_else(|_| Game::new());
Self {
engine,
config,
game: internal,
opening_book,
}
}
pub fn sync(&mut self, game: &Game) -> ChessResult<()> {
self.game = Game::from_fen(&game.get_fen())?;
Ok(())
}
pub fn do_move(&mut self, mv_uci: &str) -> ChessResult<MoveClassification> {
let board = self.game.current_board().clone();
let mc = self.classify_board(&board, mv_uci, 1)?;
self.game.do_move(mv_uci)?;
Ok(mc)
}
pub fn analyze_pgn<'a>(&'a mut self, pgn: &str) -> PgnAnalysisBuilder<'a, 'e> {
PgnAnalysisBuilder {
analyzer: self,
pgn: pgn.to_owned(),
progress: None,
min_classification: None,
side: None,
}
}
pub fn classify_move(&mut self, game: &Game, mv_uci: &str) -> ChessResult<MoveClassification> {
self.classify_board(game.current_board(), mv_uci, 1)
}
pub fn classify_fen_move(
&mut self,
fen: &str,
mv_uci: &str,
) -> ChessResult<MoveClassification> {
let board = crate::fen::parse_fen(fen)?;
self.classify_board(&board, mv_uci, 1)
}
fn classify_board(
&mut self,
board_before: &Board,
mv_uci: &str,
move_number: u32,
) -> ChessResult<MoveClassification> {
let color = board_before.side_to_move;
let legal = generate_legal_moves(board_before);
let played_move = legal
.iter()
.find(|m| m.to_uci() == mv_uci)
.ok_or_else(|| ChessError::new(format!("Illegal move: '{mv_uci}'")))?
.clone();
let san = move_to_san(board_before, &played_move);
let is_forced = legal.len() == 1;
let in_check_before = is_in_check(board_before, color);
let mut search = self.config.search.clone();
if let Some(t) = self.config.read_timeout_ms {
search.read_timeout_ms = Some(t);
}
let eval_config = SearchConfig {
multipv: Some(2),
..search.clone()
};
let top = self
.engine
.top_moves_from_board(board_before, 2, &eval_config)
.map_err(ChessError::from)?;
let best_move_uci = top
.first()
.map(|(mv, _)| mv.clone())
.unwrap_or_else(|| mv_uci.to_owned());
let eval_before = normalize_eval_to_white(
top.first()
.and_then(|(_, s)| s.as_ref())
.map(score_to_eval)
.unwrap_or(Evaluation::Centipawn(0)),
color,
);
let second_best_eval = top
.get(1)
.and_then(|(_, s)| s.as_ref())
.map(score_to_eval)
.map(|e| normalize_eval_to_white(e, color));
let best_move = legal
.iter()
.find(|m| m.to_uci() == best_move_uci)
.cloned()
.unwrap_or(played_move.clone());
let board_after = apply_move(board_before, &played_move);
let is_book = self
.opening_book
.lookup(&board_after.fen_piece_placement())
.is_some();
let eval_after = if generate_legal_moves(&board_after).is_empty() {
if is_in_check(&board_after, board_after.side_to_move) {
normalize_eval_to_white(Evaluation::Mate(0), color)
} else {
Evaluation::Centipawn(0)
}
} else {
normalize_eval_to_white(
self.engine
.evaluate_board(&board_after, &search)
.map_err(ChessError::from)?
.map(|score: Score| score_to_eval(&score))
.unwrap_or(Evaluation::Centipawn(0)),
board_after.side_to_move,
)
};
let point_loss = get_expected_points_loss(&eval_before, &eval_after, color);
let accuracy = classifications::get_move_accuracy(point_loss);
let ctx = ClassificationContext {
played_move: &played_move,
best_move: &best_move,
eval_before: &eval_before,
eval_after: &eval_after,
second_best_eval: second_best_eval.as_ref(),
point_loss,
is_book,
is_forced,
in_check_before,
color,
};
let kind = classifications::classify(board_before, &board_after, &ctx);
Ok(MoveClassification {
san,
played_move: mv_uci.to_owned(),
best_move: best_move_uci,
kind,
color,
move_number,
point_loss,
accuracy,
eval_before,
eval_after,
})
}
}