xiangqi_tui 0.1.0

Chinese chess (Xiangqi) TUI client with UCI/UCCI engine and opening book support
use crate::{
    book::BookResponse,
    engine::{
        AnalysisSnapshot, EngineAnalysisStore, EngineAnalyzeResult,
        uci_ucci_engine::{
            info_state::uci_xiangqi_best_ready,
            ui_helpers::{move_human_from_fen, red_black_winrate_pct_from_wdl},
        },
    },
    game::BoardArrow,
};

#[derive(Debug, Default, Clone, Copy)]
pub struct AnalysisService;

impl AnalysisService {
    pub fn idle_snapshot(&self) -> AnalysisSnapshot {
        AnalysisSnapshot::idle()
    }

    pub fn apply_book_response(&self, snapshot: &mut AnalysisSnapshot, response: &BookResponse) {
        snapshot.source = response.source.clone();
        snapshot.best_move = response
            .best_move
            .clone()
            .unwrap_or_else(|| "--".to_string());
        let book_rate = response
            .move_eval
            .as_ref()
            .and_then(|eval| {
                eval.winrate_raw
                    .clone()
                    .or_else(|| eval.winrate.map(|rate| format!("{rate:.1}%")))
            })
            .or_else(|| response.best_winrate.map(|rate| format!("{rate:.1}%")));
        snapshot.score_text = book_rate.clone().unwrap_or_else(|| "--".to_string());
        snapshot.win_rate_text = book_rate
            .map(|rate| format!("{rate}/--"))
            .unwrap_or_else(|| "--".to_string());
        snapshot.pv = response
            .candidates
            .iter()
            .filter_map(|c| c.move_uci.clone())
            .take(16)
            .collect();
    }

    pub fn apply_engine_store(
        &self,
        snapshot: &mut AnalysisSnapshot,
        store: &EngineAnalysisStore,
        show_analysis_arrow: bool,
        pending_arrow: &mut Option<BoardArrow>,
    ) {
        self.apply_engine_result(snapshot, &store.result, &store.fen);
        self.sync_analysis_arrow(&store.result.best_move, show_analysis_arrow, pending_arrow);
    }

    /// 查询模式与实时评估均在落子前展示推荐箭头(对齐 GUI 查询/评估 UI)。
    pub fn sync_analysis_arrow(
        &self,
        uci_best: &str,
        show_arrow: bool,
        pending_arrow: &mut Option<BoardArrow>,
    ) {
        if show_arrow && let Some(arrow) = board_arrow_from_uci(uci_best) {
            *pending_arrow = Some(arrow);
        }
    }

    pub fn apply_engine_result(
        &self,
        snapshot: &mut AnalysisSnapshot,
        result: &EngineAnalyzeResult,
        fen: &str,
    ) {
        snapshot.source = "engine".to_string();
        snapshot.depth = result.depth.unwrap_or(0).max(0) as u16;
        snapshot.nps = result.nps.unwrap_or(0);
        snapshot.nodes = result.nodes.unwrap_or(0);
        snapshot.time_text = format_time_ms(result.search_time_ms);
        snapshot.score_text = format_score(result);
        let uci = result.best_move.trim();
        snapshot.best_move = if uci_xiangqi_best_ready(uci) {
            move_human_from_fen(fen, uci)
        } else {
            uci.to_string()
        };
        let (red, black) = red_black_winrate_pct_from_wdl(fen, result.wdl);
        snapshot.win_rate_text = format_win_rate(red, black);
        snapshot.pv = if result.pv.is_empty() {
            result
                .candidates
                .first()
                .map(|c| c.pv.clone())
                .unwrap_or_default()
        } else {
            result.pv.clone()
        };
    }
}

fn format_time_ms(ms: Option<u64>) -> String {
    match ms {
        Some(v) if v >= 1000 => format!("{:.2}s", v as f64 / 1000.0),
        Some(v) => format!("{v}ms"),
        None => "--".to_string(),
    }
}

fn format_score(result: &EngineAnalyzeResult) -> String {
    if let Some(mate) = result.mate {
        return format!("M{mate}");
    }
    if let Some(cp) = result.score_cp {
        let pawns = cp as f64 / 100.0;
        if pawns > 0.0 {
            return format!("+{pawns:.2}");
        }
        return format!("{pawns:.2}");
    }
    if result.score.abs() < f64::EPSILON {
        return "0".to_string();
    }
    if result.score > 0.0 {
        format!("+{:.2}", result.score)
    } else {
        format!("{:.2}", result.score)
    }
}

fn format_win_rate(red: Option<f64>, black: Option<f64>) -> String {
    match (red, black) {
        (Some(r), Some(b)) => format!("{r:.0}%/{b:.0}%"),
        _ => "--/--".to_string(),
    }
}

fn board_arrow_from_uci(uci: &str) -> Option<BoardArrow> {
    if !uci_xiangqi_best_ready(uci) {
        return None;
    }
    crate::service::game::arrow_from_uci(uci)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::analysis_types::EngineAnalyzeResult;

    #[test]
    fn format_score_mate_and_cp() {
        let mate = EngineAnalyzeResult {
            mate: Some(3),
            ..EngineAnalyzeResult::default()
        };
        assert_eq!(format_score(&mate), "M3");

        let cp = EngineAnalyzeResult {
            score_cp: Some(45),
            ..EngineAnalyzeResult::default()
        };
        assert_eq!(format_score(&cp), "+0.45");
    }

    #[test]
    fn board_arrow_parses_valid_uci() {
        let arrow = board_arrow_from_uci("h2e2").expect("arrow");
        assert_eq!(arrow.from_file, 7);
        assert_eq!(arrow.from_rank, 7);
        assert!(board_arrow_from_uci("stub_move").is_none());
    }

    #[test]
    fn sync_analysis_arrow_respects_show_flag() {
        let svc = AnalysisService;
        let mut arrow = None;
        svc.sync_analysis_arrow("h2e2", false, &mut arrow);
        assert!(arrow.is_none());
        svc.sync_analysis_arrow("h2e2", true, &mut arrow);
        assert!(arrow.is_some());
    }

    #[test]
    fn apply_book_response_fills_snapshot() {
        use crate::book::{BookCandidate, BookResponse};
        let svc = AnalysisService;
        let mut snap = AnalysisSnapshot::idle();
        let response = BookResponse {
            source: "obk".to_string(),
            best_move: Some("h2e2".to_string()),
            best_winrate: Some(55.5),
            candidates: vec![BookCandidate {
                move_uci: Some("h2e2".to_string()),
                ..BookCandidate::default()
            }],
            ..BookResponse::default()
        };
        svc.apply_book_response(&mut snap, &response);
        assert_eq!(snap.source, "obk");
        assert_eq!(snap.best_move, "h2e2");
        assert_eq!(snap.pv, vec!["h2e2"]);
    }

    #[test]
    fn apply_engine_result_keeps_global_uci() {
        let svc = AnalysisService;
        let mut snap = AnalysisSnapshot::idle();
        let result = EngineAnalyzeResult {
            best_move: "h2e2".to_string(),
            depth: Some(18),
            pv: vec!["h2e2".to_string(), "h7e7".to_string()],
            ..EngineAnalyzeResult::default()
        };
        let fen = "rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1";
        svc.apply_engine_result(&mut snap, &result, fen);
        assert_eq!(snap.best_move, "h2e2");
        assert_eq!(snap.pv, vec!["h2e2", "h7e7"]);
    }
}