Skip to main content

chess_tui/game_logic/
bot.rs

1use crate::constants::{BOT_DIFFICULTY_DEPTH, BOT_DIFFICULTY_ELO, BOT_DIFFICULTY_MOVETIME_MS};
2use ruci::{Engine, Go};
3use shakmaty::fen::Fen;
4use shakmaty::uci::UciMove;
5use std::borrow::Cow;
6use std::process::Command;
7use std::str::FromStr;
8
9#[derive(Clone)]
10pub struct Bot {
11    pub engine_path: String,
12    /// Used to indicate if a bot move is following
13    pub bot_will_move: bool,
14    // if the bot is starting, meaning the player is black
15    pub is_bot_starting: bool,
16    /// Base depth when difficulty is Off (full strength)
17    pub depth: u8,
18    /// Difficulty preset index: None = Off (full strength), Some(0..=3) = Easy/Medium/Hard/Magnus
19    pub difficulty: Option<u8>,
20}
21
22impl Bot {
23    pub fn new(engine_path: &str, is_bot_starting: bool, depth: u8, difficulty: Option<u8>) -> Bot {
24        Self {
25            engine_path: engine_path.to_string(),
26            bot_will_move: false,
27            is_bot_starting,
28            depth,
29            difficulty,
30        }
31    }
32
33    /// Effective depth: preset depth when difficulty is set, else base depth.
34    fn effective_depth(&self) -> u8 {
35        self.difficulty
36            .and_then(|i| {
37                let idx = i as usize;
38                if idx < BOT_DIFFICULTY_DEPTH.len() {
39                    Some(BOT_DIFFICULTY_DEPTH[idx])
40                } else {
41                    None
42                }
43            })
44            .unwrap_or(self.depth)
45    }
46
47    /// Movetime in ms when difficulty is set; None = no limit (full strength).
48    fn movetime_ms(&self) -> Option<u64> {
49        self.difficulty.and_then(|i| {
50            let idx = i as usize;
51            if idx < BOT_DIFFICULTY_MOVETIME_MS.len() {
52                Some(BOT_DIFFICULTY_MOVETIME_MS[idx])
53            } else {
54                None
55            }
56        })
57    }
58
59    /// ELO for UCI_LimitStrength when difficulty is set.
60    fn elo(&self) -> Option<u16> {
61        self.difficulty.and_then(|i| {
62            let idx = i as usize;
63            if idx < BOT_DIFFICULTY_ELO.len() {
64                Some(BOT_DIFFICULTY_ELO[idx])
65            } else {
66                None
67            }
68        })
69    }
70
71    /// Get the best move from the chess engine.
72    ///
73    /// # Panics
74    ///
75    /// Panics if:
76    /// - The engine process fails to spawn
77    /// - The engine fails to initialize
78    /// - The FEN string is invalid
79    /// - The engine fails to return a move
80    pub fn get_move(&self, fen: &str) -> UciMove {
81        // Parse engine_path to support command-line arguments
82        // Split by spaces, treating first part as command and rest as args
83        let parts: Vec<&str> = self.engine_path.split_whitespace().collect();
84        let (command, args) = if parts.is_empty() {
85            (self.engine_path.as_str(), &[] as &[&str])
86        } else {
87            (parts[0], &parts[1..])
88        };
89
90        let mut cmd = Command::new(command);
91        if !args.is_empty() {
92            cmd.args(args);
93        }
94
95        let mut process = cmd
96            .stdin(std::process::Stdio::piped())
97            .stdout(std::process::Stdio::piped())
98            .spawn()
99            .unwrap_or_else(|e| panic!("Failed to spawn engine process: {e}"));
100
101        let mut engine = Engine::from_process(&mut process, false)
102            .unwrap_or_else(|e| panic!("Failed to initialize engine: {e}"));
103
104        // UCI handshake (required before setoption/position). Discard options.
105        engine
106            .use_uci(|_| {})
107            .unwrap_or_else(|e| panic!("Failed UCI handshake: {e}"));
108
109        // Optional ELO limit via UCI options (UCI_LimitStrength + UCI_Elo) when difficulty is set
110        if let Some(elo) = self.elo() {
111            engine
112                .send(ruci::gui::SetOption {
113                    name: Cow::Borrowed("UCI_LimitStrength"),
114                    value: Some(Cow::Borrowed("true")),
115                })
116                .unwrap_or_else(|e| panic!("Failed to set UCI_LimitStrength: {e}"));
117            engine
118                .send(ruci::gui::SetOption {
119                    name: Cow::Borrowed("UCI_Elo"),
120                    value: Some(Cow::Owned(elo.to_string())),
121                })
122                .unwrap_or_else(|e| panic!("Failed to set UCI_Elo: {e}"));
123        }
124
125        let fen_parsed =
126            Fen::from_str(fen).unwrap_or_else(|e| panic!("Failed to parse FEN '{fen}': {e}"));
127
128        engine
129            .send(ruci::Position::Fen {
130                fen: Cow::Owned(fen_parsed),
131                moves: Cow::Borrowed(&[]),
132            })
133            .unwrap_or_else(|e| panic!("Failed to send position to engine: {e}"));
134
135        let depth = self.effective_depth();
136        let move_time_ms = self.movetime_ms();
137
138        engine
139            .go(
140                &Go {
141                    depth: Some(depth as usize),
142                    move_time: move_time_ms.map(|ms| ms as usize),
143                    ..Default::default()
144                },
145                |_| {},
146            )
147            .unwrap_or_else(|e| panic!("Engine failed to compute move: {e}"))
148            .take_normal()
149            .unwrap_or_else(|| panic!("Engine returned non-normal move"))
150            .r#move
151    }
152}