1use crate::{
2 Evaluation, EvaluationProfile, GameResult, Move, MoveList, Position, Square, evaluate,
3};
4
5const INFINITY: i32 = 40_000;
6const TERMINAL: i32 = 30_000;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub struct Score(i32);
11
12impl Score {
13 pub const fn raw(self) -> i32 {
14 self.0
15 }
16
17 pub const fn kind(self) -> ScoreKind {
18 if self.0 >= TERMINAL {
19 ScoreKind::Win {
20 margin: (self.0 - TERMINAL) as u8,
21 }
22 } else if self.0 <= -TERMINAL {
23 ScoreKind::Loss {
24 margin: (-self.0 - TERMINAL) as u8,
25 }
26 } else {
27 ScoreKind::Estimate(self.0)
28 }
29 }
30
31 const fn negated(self) -> Self {
32 Self(-self.0)
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ScoreKind {
38 Win { margin: u8 },
39 Draw,
40 Loss { margin: u8 },
41 Estimate(i32),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct SearchConfig {
46 pub depth: u8,
47 pub evaluator: EvaluationProfile,
48 pub exact_endgame_empties: u8,
49}
50
51impl SearchConfig {
52 pub const fn fixed_depth(depth: u8, evaluator: EvaluationProfile) -> Self {
53 Self {
54 depth,
55 evaluator,
56 exact_endgame_empties: 0,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct SearchPreset {
64 pub name: &'static str,
65 pub depth: u8,
66 pub exact_endgame_empties: u8,
67}
68
69impl SearchPreset {
70 pub const fn config(self, evaluator: EvaluationProfile) -> SearchConfig {
71 SearchConfig {
72 depth: self.depth,
73 evaluator,
74 exact_endgame_empties: self.exact_endgame_empties,
75 }
76 }
77}
78
79pub const SEARCH_PRESETS: [SearchPreset; 6] = [
81 SearchPreset {
82 name: "beginner",
83 depth: 1,
84 exact_endgame_empties: 0,
85 },
86 SearchPreset {
87 name: "easy",
88 depth: 2,
89 exact_endgame_empties: 0,
90 },
91 SearchPreset {
92 name: "medium",
93 depth: 4,
94 exact_endgame_empties: 2,
95 },
96 SearchPreset {
97 name: "hard",
98 depth: 5,
99 exact_endgame_empties: 4,
100 },
101 SearchPreset {
102 name: "expert",
103 depth: 6,
104 exact_endgame_empties: 8,
105 },
106 SearchPreset {
107 name: "maximum",
108 depth: 7,
109 exact_endgame_empties: 8,
110 },
111];
112
113pub fn search_preset(name: &str) -> Option<SearchPreset> {
115 SEARCH_PRESETS
116 .iter()
117 .copied()
118 .find(|preset| preset.name == name)
119}
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
122pub struct SearchStats {
123 pub nodes: u64,
124 pub leaves: u64,
125 pub cutoffs: u64,
126 pub passes: u64,
127 pub exact_nodes: u64,
128 pub max_ply: u8,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Candidate {
133 pub mv: Move,
134 pub score: Score,
135 pub nodes: u64,
136 pub cutoffs: u64,
137 pub flipped: u64,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct SearchReport {
143 pub config: SearchConfig,
144 pub best_move: Option<Move>,
145 pub score: Score,
146 pub exact: bool,
147 pub principal_variation: Vec<Move>,
148 pub candidates: Vec<Candidate>,
149 pub evaluation: Evaluation,
150 pub stats: SearchStats,
151}
152
153#[derive(Debug, Clone, Copy)]
154struct Line {
155 moves: [Move; 64],
156 len: u8,
157}
158
159impl Default for Line {
160 fn default() -> Self {
161 Self {
162 moves: [Move::Pass; 64],
163 len: 0,
164 }
165 }
166}
167
168impl Line {
169 fn prepend(mv: Move, child: Self) -> Self {
170 let mut line = Self::default();
171 line.moves[0] = mv;
172 let child_len = usize::from(child.len).min(63);
173 if child_len > 0 {
174 line.moves[1..=child_len].copy_from_slice(&child.moves[..child_len]);
175 }
176 line.len = child_len as u8 + 1;
177 line
178 }
179
180 fn to_vec(self) -> Vec<Move> {
181 self.moves[..usize::from(self.len)].to_vec()
182 }
183}
184
185#[derive(Debug, Clone, Copy)]
186struct NodeResult {
187 score: Score,
188 line: Line,
189}
190
191struct Searcher<F: Fn() -> bool> {
192 config: SearchConfig,
193 stats: SearchStats,
194 should_stop: F,
195 stopped: bool,
196}
197
198impl<F: Fn() -> bool> Searcher<F> {
199 fn stop_requested(&mut self) -> bool {
200 if self.stopped || (self.should_stop)() {
201 self.stopped = true;
202 }
203 self.stopped
204 }
205
206 fn node(
207 &mut self,
208 position: &mut Position,
209 depth: u8,
210 ply: u8,
211 mut alpha: Score,
212 beta: Score,
213 ) -> Option<NodeResult> {
214 if self.stop_requested() {
215 return None;
216 }
217 self.stats.nodes += 1;
218 self.stats.max_ply = self.stats.max_ply.max(ply);
219 if position.empty_count() <= self.config.exact_endgame_empties {
220 self.stats.exact_nodes += 1;
221 }
222 if let Some(score) = terminal_score(*position) {
223 self.stats.leaves += 1;
224 return Some(NodeResult {
225 score,
226 line: Line::default(),
227 });
228 }
229 let exact = position.empty_count() <= self.config.exact_endgame_empties;
230 if depth == 0 && !exact {
231 self.stats.leaves += 1;
232 return Some(NodeResult {
233 score: Score(evaluate(*position, self.config.evaluator).total),
234 line: Line::default(),
235 });
236 }
237
238 let mut best = Score(-INFINITY);
239 let mut best_move = None;
240 let mut best_line = Line::default();
241 for mv in ordered_moves(*position) {
242 if mv == Move::Pass {
243 self.stats.passes += 1;
244 }
245 let undo = position
246 .make_move(mv)
247 .expect("generated moves must be legal");
248 let next_depth = if mv == Move::Pass {
249 depth
250 } else {
251 depth.saturating_sub(1)
252 };
253 let child = self.node(
254 position,
255 next_depth,
256 ply + 1,
257 beta.negated(),
258 alpha.negated(),
259 );
260 position.unmake_move(undo);
261 let child = child?;
262 let score = child.score.negated();
263 if score > best || (score == best && prefer(mv, best_move)) {
264 best = score;
265 best_move = Some(mv);
266 best_line = Line::prepend(mv, child.line);
267 }
268 alpha = alpha.max(score);
269 if alpha >= beta {
270 self.stats.cutoffs += 1;
271 break;
272 }
273 }
274 Some(NodeResult {
275 score: best,
276 line: best_line,
277 })
278 }
279}
280
281pub fn search(position: Position, config: SearchConfig) -> SearchReport {
283 search_until(position, config, || false)
284 .expect("a search with a constant false stop signal must complete")
285}
286
287pub fn search_until<F: Fn() -> bool>(
290 position: Position,
291 config: SearchConfig,
292 should_stop: F,
293) -> Option<SearchReport> {
294 let exact = position.empty_count() <= config.exact_endgame_empties;
295 let evaluation = evaluate(position, config.evaluator);
296 let mut searcher = Searcher {
297 config,
298 stats: SearchStats {
299 nodes: 1,
300 ..SearchStats::default()
301 },
302 should_stop,
303 stopped: false,
304 };
305 if searcher.stop_requested() {
306 return None;
307 }
308 if let Some(score) = terminal_score(position) {
309 searcher.stats.leaves = 1;
310 return Some(SearchReport {
311 config,
312 best_move: None,
313 score,
314 exact: true,
315 principal_variation: Vec::new(),
316 candidates: Vec::new(),
317 evaluation,
318 stats: searcher.stats,
319 });
320 }
321 if config.depth == 0 && !exact {
322 searcher.stats.leaves = 1;
323 return Some(SearchReport {
324 config,
325 best_move: None,
326 score: Score(evaluation.total),
327 exact: false,
328 principal_variation: Vec::new(),
329 candidates: Vec::new(),
330 evaluation,
331 stats: searcher.stats,
332 });
333 }
334
335 let mut best = Score(-INFINITY);
336 let mut best_move = None;
337 let mut best_line = Line::default();
338 let mut candidates = Vec::new();
339 for mv in ordered_moves(position) {
340 if searcher.stop_requested() {
341 return None;
342 }
343 if mv == Move::Pass {
344 searcher.stats.passes += 1;
345 }
346 let before_nodes = searcher.stats.nodes;
347 let before_cutoffs = searcher.stats.cutoffs;
348 let flipped = mv.square().map_or(0, |square| position.flips_for(square));
349 let mut child_position = position;
350 child_position
351 .make_move(mv)
352 .expect("generated moves must be legal");
353 let next_depth = if mv == Move::Pass {
354 config.depth
355 } else {
356 config.depth.saturating_sub(1)
357 };
358 let child = searcher.node(
359 &mut child_position,
360 next_depth,
361 1,
362 Score(-INFINITY),
363 Score(INFINITY),
364 )?;
365 let score = child.score.negated();
366 candidates.push(Candidate {
367 mv,
368 score,
369 nodes: searcher.stats.nodes - before_nodes,
370 cutoffs: searcher.stats.cutoffs - before_cutoffs,
371 flipped,
372 });
373 if score > best || (score == best && prefer(mv, best_move)) {
374 best = score;
375 best_move = Some(mv);
376 best_line = Line::prepend(mv, child.line);
377 }
378 }
379
380 Some(SearchReport {
381 config,
382 best_move,
383 score: best,
384 exact,
385 principal_variation: best_line.to_vec(),
386 candidates,
387 evaluation,
388 stats: searcher.stats,
389 })
390}
391
392fn terminal_score(position: Position) -> Option<Score> {
393 match position.result() {
394 GameResult::Ongoing => None,
395 GameResult::Draw { .. } => Some(Score(0)),
396 GameResult::Win {
397 winner,
398 black,
399 white,
400 } => {
401 let margin = i32::from(black.abs_diff(white));
402 if winner == position.side_to_move() {
403 Some(Score(TERMINAL + margin))
404 } else {
405 Some(Score(-TERMINAL - margin))
406 }
407 }
408 }
409}
410
411fn ordered_moves(position: Position) -> MoveList {
412 const CORNERS: [u8; 4] = [0, 7, 56, 63];
413 let legal = position.legal_moves();
414 if legal.as_slice() == [Move::Pass] {
415 return legal;
416 }
417 let mut ordered = MoveList::default();
418 for index in CORNERS {
419 let mv = Move::Place(Square::new(index));
420 if legal.as_slice().contains(&mv) {
421 ordered.push(mv);
422 }
423 }
424 for mv in legal {
425 if !ordered.as_slice().contains(&mv) {
426 ordered.push(mv);
427 }
428 }
429 ordered
430}
431
432fn prefer(candidate: Move, current: Option<Move>) -> bool {
433 current.is_none_or(|mv| candidate < mv)
434}