diff --git a/.gitignore b/.gitignore index 6a7808d..996a570 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,4 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb -sharpener/ \ No newline at end of file +sharpener \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 00ea054..a9fce0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maxwell" -version = "3.0.8" +version = "3.1.0" edition = "2021" [dependencies] diff --git a/README.md b/README.md index c4f4bd8..e7f2a88 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,30 @@ ![](/icon/Maxwell_316x316.png) # Maxwell Chess Engine - A Chess engine written from scratch in Rust!
+ A Chess engine written from scratch in Rust.
If you use this code verbatim, or as a reference, please credit me!

+ + Rating Lists featuring Maxwell: [CCRL Blitz](https://computerchess.org.uk/ccrl/404/) | [MCERL](https://www.chessengeria.eu/mcerl)
[Play against Maxwell on Lichess!](https://lichess.org/@/MaxwellOnLC) | [Some of Maxwell's Games](https://www.chess.com/library/collections/maxwells-games-my-chess-engine-2FFU82NM4) +## Rough Roadmap + - Tweak search values & thresholds + - Internal Iterative Deepening + - Static Exchange Evaluation + - Late Move Pruning + - Write an NNUE implementation! I've learned how Neural Networks work, so I'm really excited to get started on that + - Multithreading + ## Features #### Parameters - fen=\: Sets up the board by a fen string (Doesn't work for UCI games) (default=STARTING_FEN) - debug=\: Toggle debug output that gets outputed per ply (default=true) - - opening_book=\: Toggle opening book (default=true) + - opening_book=\: Toggle built-in opening book (default=false) - time_management=\: Toggle time management, if false the bot will use all the remaining time (default=true) + - hash_size=\: Sets the hash size in Megabytes, there's also a UCI option for this under the name "Hash" (default=256) #### UCI Interface - uci, isready, ucinewgame, position, go, stop, and quit commands - "position" is only implemented for "position startpos", "position fen" is not yet implemented + - "Hash" UCI option, which sets the hash / transposition table size in Megabytes #### Board Representation - Purely bitboards - Supports loading from FEN strings @@ -21,6 +33,7 @@ - Magic bitboards for sliding pieces - Hardcoded pawn movement - Bitboard masks for other pieces calculated at startup + - Calculates pseudo-legal moves, then skips illegal moves in move loop #### Evaluation - Material count - Piece square tables @@ -28,36 +41,58 @@ - Passed, isolated and doubled pawns - Attacked squares around kings #### Move Ordering - - Hash move / best move from previous iteration + - Best move from the previous iteration, otherwise whatever move from the transposition table - MVV-LVA - - 2 Killer moves - - History heuristic - - Castling - - Promotions - - Penalty for moving a piece to a square an opponent's piece attacks + - 2 Killer Moves + - History Heuristic + - Indexed by side to move, move start square, move end square #### Search - - Iterative deepening - - Aspiration windows - - Starts at 40 and multiplies by 4 if out of alpha beta bounds - - Time management - - If less than 7 moves have been played, it uses 2.5% of it's remaining time, otherwise 7% - - This value is then also clamped between 0.25 and 20.0 seconds - - Exits search if a mate is found within search depth - - Alpha beta pruning - - Quiescence search with Delta Pruning - - Transposition table - - No set max size, but entries get removed after 10 moves without hits - - Null move pruning + - Single Threaded ~ For now ;) + - Negamax + - Iterative Deepening + - Alpha-Beta Pruning + - Late Move Reductions + - Principal Variation Search + - Reverse Futility Pruning (Static Null Move Pruning) + - Null Move Pruning - Razoring - - Reverse futility pruning - - Late move reduction - - Search extensions - - Promotions + - Internal Iterative Reductions + - Quiescence Search + - Delta Pruning + - No TT Lookups + - Transposition Table + - UCI "Hash" option to change max size, default is 256 MB + - Replacement scheme prefers higher depth and exact evaluation bound + - Search Extensions - Checks + - Pawn moves to the 2nd or 7th rank + - Time management + - If less than 7 moves have been played, it uses 2.5% of it's remaining time, otherwise 7% + - This value is then also clamped between 0.05 and 30.0 seconds -## Helpful Sources +## Helpful Sources & References + #### Thanks to Sebastian Lague for making his YouTube series, which inspired me to make my own engine! - [Sebastian Lague's Chess Programming series](https://www.youtube.com/playlist?list=PLFt_AvWsXl0cvHyu32ajwh2qU1i6hl77c) + + #### When I'm not sure what to do next, I like to read through other engine's code for ideas.
I try not to copy line for line, but in any case here are the engine's I've referenced: + - [Boychesser](https://github.com/analog-hors/Boychesser/) + - [Weiawaga](https://github.com/Heiaha/Weiawaga/) + - [Rustic (Engine and Book)](https://github.com/mvanthoor/rustic) + - [Lynx](https://github.com/lynx-chess/Lynx/) + - [Fruit 2.1](https://github.com/Warpten/Fruit-2.1/) + - [Tcheran](https://github.com/jgilchrist/tcheran/) + - [MadChess](https://github.com/ekmadsen/MadChess/) + - [Black Marlin](https://github.com/jnlt3/blackmarlin/) + - [Ethereal](https://github.com/AndyGrant/Ethereal/) + + #### And some other helpful resources - [The Chess Programming Wiki](https://www.chessprogramming.org/Main_Page) - [BBC Engine Development](https://www.youtube.com/playlist?list=PLmN0neTso3Jxh8ZIylk74JpwfiWNI76Cs) - - [Lynx](https://github.com/lynx-chess/Lynx/) - - [Weiawaga](https://github.com/Heiaha/Weiawaga/) \ No newline at end of file + - [Perfect 2021 Opening Book](https://sites.google.com/site/computerschess/perfect-2021-books) + - [Cute Chess](https://cutechess.com/) + - [PVS Implementation](https://web.archive.org/web/20071030220825/http://www.brucemo.com/compchess/programming/pvs.htm) + - [LMR Implementation](https://web.archive.org/web/20150212051846/http://www.glaurungchess.com/lmr.html) + - [Mediocre Chess](https://mediocrechess.blogspot.com/) + - [Chess Programming Reddit](https://www.reddit.com/r/chessprogramming/) + - [TalkChess Forum](https://talkchess.com/forum3/index.php) + - [Stockfish Features List](https://www.chessprogramming.org/Stockfish#Search) \ No newline at end of file diff --git a/Test results.txt b/Test results.txt new file mode 100644 index 0000000..54f3cbd --- /dev/null +++ b/Test results.txt @@ -0,0 +1,207 @@ +For X - Y - Z the order is: Wins, Losses, Draws +I used to use 30+0.5 and 10+0.2, but lately I've been using 8+0.08 +================================================================ + + +Re-write v1 vs v3.0.8: 17 - 14 - 19 + + + Elo +/- Games Score Draw +1 Re-write v2 63 58 100 59.0% 30.0% +2 Re-write v1 -7 58 100 49.0% 28.0% +3 Maxwell v3.0.8 -56 56 100 42.0% 34.0% + + +This is for when to return a TT evaluation +Re-write v3 (Not root) vs Re-write v3 (Not PV): 131 - 60 - 109 +Re-write v3 (Not root) vs Re-write v3 (All): 33 - 33 - 36 +Re-write v3 (Depth replace) vs Re-write v3 (Always replace): 74 - 60 - 66 + + +TT replacement schemes + Elo +/- Games Points Draw +1 Re-write v3 (Higher depth, exact bound) 37 37 200 110.5 40.5% +2 Re-write v3 (Higher depth, equals key) -7 37 200 98.0 40.0% +3 Re-write v3 (Higher depth) -30 38 200 91.5 39.5% + + +v4 contains fixes for Null Move Pruning + Elo +/- Games Points Draw +1 Re-write v4 33 45 150 82.0 34.7% +2 Re-write v3 12 46 150 77.5 32.7% +3 Re-write v2 -44 43 150 65.5 40.7% + + +Promotion extension tests + Elo +/- Games Points Draw +Pawn one step from promotion 26 45 150 80.5 35.3% +Promotion flag -7 45 150 73.5 35.3% +Neither -19 45 150 71.0 34.7% + + +Re-write v4 vs Aspiration window drops after out of bounds: 275 - 263 - 262 + + +Re-write v5 (Uses evaluation from the last iteration instead from the last search) vs Re-write v4: 106 - 93 - 101 + + +Removed capture check before doing pruning techniques vs Re-write v5: 126 - 98 - 76 + + +Capture check only before NMP vs Re-write v5 (Capture check before any pruning) 54 - 37 - 35 + + + Elo +/- Games Points Draw +Re-write v6 (Capture check before NMP) 17 20 800 419.0 31.3% +Removed capture check 7 20 800 408.0 31.5% +Re-write v5 (Capture check before pruning) -23 20 800 373.0 29.8% + + +Re-write v7 (RFP before NMP) vs Re-write v6: 136 - 120 - 144 + + +Re-write v8 (New PVS) vs Re-write v7: 161 - 117 - 122 + + +Re-write v9 (LMR & PVS changes) vs Re-write v8 (New PVS): 418 - 241 - 341 + + +Re-write v10 vs Re-write v9: 138 - 135 - 127 + + +Re-write v10 vs Maxwell v3.0.8: 754 - 171 - 331 (1256 games!) + + +Re-write v10 vs Added extension to PVS searches: 99 - 85 - 116 (?) + + +Re-write v11 vs Re-write v10: 376 - 343 - 481 + + +With alpha TT stores vs Without alpha TT stores (Re-write v11): 239 - 256 - 355 + + +Don't allow more than one null move in the search tree at once vs Re-write v11: 175 - 167 - 268 + Techincally a win, but the scores were fluctuating a good bit, and I'm not so sure about it + + +v12 vs v11 was equal, because v12 was just a bit of code cleanup + + +Strelka razoring vs Re-write v12: 234 - 304 - 368 + + +NMP changes (> 1, and 3 + (depth - 2) / 5) vs Re-write v12: 255 - 254 - 391 + + +Re-write v13 (NMP changes: > 1, and 3 + (depth - 2) / 3) vs Re-write v12: 171 - 122 - 207 + + +History indexed by [from][to] vs Re-write v13: 76 - 77 - 113 + + +Re-write v14 (History indexed by [side to move][from][to]) vs Re-write v13: 152 - 125 - 223 + + +The move order penalty when the target square is attacked by the enemy + Elo +/- Games Points Wins Draws +Re-write v15 (Removed) 27 16 1000 539.0 320 438 +Re-write v14 -13 16 1000 481.0 259 444 +Re-write v15 (Quiets only) -14 16 1000 480.0 262 436 + + +Countermoves vs Re-write v15: 122 - 177 - 201 + + +v16 changed TT to use a Vec instead of a HashMap, so there should be no strength difference + + +Re-write v17 (Re-wrote killer moves) vs Re-write v16: 173 - 122 - 205 + + +Re-write v17 vs Maxwell v3.0.8 (Patch 2): 968 - 136 - 476 (1580 games, and an estimated +190 Elo!!) + + +Count plies since null moves in is_repetition vs Re-write v17: 140 - 149 - 211 + + +Re-write v18 (Add null moves to repetition lookback) vs Re-write v17: 350 - 327 - 523 + + +Re-write v19 (FINALLY FIXED REPETITION DETECTION) vs Re-write v18: 509 - 429 - 662 + + +Incremental move sorter like Rustic vs Re-write v19: 153 - 171 - 176 + + +Extensions outside the move loop vs Re-write v19: 576 - 581 - 593 + + +Only check extension, outside the move loop vs Re-write v19: 272 - 276 - 252 + + +Re-write v20 (Rewrote extension code) vs Re-write v19: 171 - 163 - 166 + + +(Re-write v20's RFP Threshold is 60 centipawns per ply) +RFP = 80 vs Re-write v20: 165 - 172 - 173 +RFP = 90 vs Re-write v20: 168 - 181 - 151 +RFP = 100 vs Re-write v20: 423 - 519 - 508 + +At this point I changed RFP to return beta (because of fail hard) + +RFP = 16 * (depth - 1).pow(2) + 50 vs Re-write v20: 196 - 217 - 191 +RFP = 30 * (depth - 1).pow(2) + 60 vs Re-write v20: 133 - 150 - 176 +RFP = 20 * (depth - 1).pow(2) + 65 vs Re-write v20: 140 - 163 - 147 +RFP = 40 * (depth - 1).pow(2) + 70 vs Re-write v20: 486 - 498 - 516 + +FINALLY +Re-write v21 (RFP = 50 * (depth - 1).pow(2) + 55) vs Re-write v20: 276 - 252 - 272 + + +Hmm +Store Alpha bound TT entries vs Re-write v21: 283 - 308 - 311 + + +No TT Cutoff on PV-nodes vs Re-write v21: 514 - 521 - 573 + + +Internal Iterative Reductions + Only on PV, Depth > 2, Depth -= 1 vs Re-write v21: 254 - 255 - 253 + Re-write v22 (All nodes, Depth > 1, Depth -= 1) vs Re-write v21: 285 - 234 - 247 + All nodes, Depth > 2, Depth -= 2 vs Re-write v22: It lost horribly :P + + +Only return TT eval if not PV, or depth == 0 vs Re-write v22: 498 - 554 - 618 + + +RFP = 55 * (depth - 1).pow(2) + 60 vs Re-write v22: 142 - 145 - 153 + + +Razoring change (depth -= 2) vs Re-write v22: 533 - 556 - 687 +Razoring change (threshold <= alpha) vs Re-write v22: 122 - 132 - 146 +Razoring change (285 + 200 * (depth - 1)) vs Re-write v22: 205 - 198 - 207 +Razoring change (300 + 150 * (depth - 1)) vs Re-write v22: 265 - 225 - 266 + + +Only NMP if no hash move vs Re-write v23: 485 - 520 - 597 + + +Aspiration Windows + Elo +/- Games Points Wins Draws Draw +AW=30 12 24 500 258.5 165 187 37.4% +AW=60 -2 25 500 248.5 167 163 32.6% +Re-write v23 (40) -5 25 500 246.5 164 165 33.0% +AW=50 -5 24 500 246.5 148 197 39.4% + + +Hmm +AW=30 vs Re-write v32: 147 - 148 - 145 + + +Maxwell v3.1 (Re-write v23) vs Maxwell v3.0.8 (Patch 2): 1533 - 155 - 312 +Elo difference: 293.9 +/- 17.6, LOS: 100.0 % + + +Tests TODO: + TT enabled vs TT disabled \ No newline at end of file diff --git a/To-do list.txt b/To-do list.txt new file mode 100644 index 0000000..8d6ae07 --- /dev/null +++ b/To-do list.txt @@ -0,0 +1,44 @@ +thoughts on NNUE: + I've wanted to learn how to write neural net for a long time, so I want to implement NNUE eventually. + But what I'm not going to do is just find a SF NNUE library and stick it in there because that's lame + + Update: + I've learned how to write neural networks, and trained one to evaluate Tic-Tac-Toe positions! + So for either v3.2 or v3.3 I'm gonna work on writing my own version of NNUE from scratch + +figure out some sort of multithreading: + to implement pondering I think I'll have to add multithreading + + maybe one thread that waits for UCI commands like "stop" + and all the rest of the threads search + + https://www.chessprogramming.org/Lazy_SMP + +transposition table: + buckets + aging + make it multithreading safe + prefetching + +try removing all the attacked squares bitboards stuff, and just make a function that detects whether one square is attacked? +try giving a small boost in evaluation for the current side to move +try lower pawn evaluation values +experiment with more than 2 killer moves per ply +calculate my own magic numbers; currently "borrowing" Sebastian Lague's ^^ +check out pin detection to speed up check detection +try writing a struct that sorts moves incrementally + I tried this a couple times, but haven't got it faster than my current solution +re-implement PV table with a different approach; I don't like the 2d array + +History reductions / pruning +https://www.chessprogramming.org/Internal_Iterative_Deepening +https://www.chessprogramming.org/Static_Exchange_Evaluation +https://www.chessprogramming.org/Futility_Pruning#MoveCountBasedPruning (Late move pruning) +https://www.chessprogramming.org/History_Leaf_Pruning +https://www.chessprogramming.org/ProbCut +https://www.chessprogramming.org/Razoring#Strelka +https://www.chessprogramming.org/Texel's_Tuning_Method + +Some random resources I found: (Not using them right now but they could be useful) +https://analog-hors.github.io/site/magic-bitboards/ +https://mediocrechess.blogspot.com/2006/12/guide-attacked-squares.html \ No newline at end of file diff --git a/src/board.rs b/src/board.rs index 140a21a..d02bb64 100644 --- a/src/board.rs +++ b/src/board.rs @@ -1,6 +1,6 @@ use crate::value_holder::ValueHolder; -use crate::utils::{pop_lsb, print_bitboard, coordinate_to_index}; -use crate::piece_square_tables::{get_base_worth_of_piece, get_full_worth_of_piece, ROOK_WORTH, BISHOP_WORTH}; +use crate::utils::{pop_lsb, get_lsb, print_bitboard, coordinate_to_index}; +use crate::piece_square_tables::{BASE_WORTHS_OF_PIECE_TYPE, get_full_worth_of_piece, ROOK_WORTH, BISHOP_WORTH}; use crate::precalculated_move_data::*; use crate::move_data::*; use crate::zobrist::Zobrist; @@ -8,34 +8,47 @@ use crate::pieces::*; use crate::castling_rights::*; use colored::Colorize; -pub const BITBOARD_COUNT: usize = PIECE_COUNT; pub const MAX_ENDGAME_MATERIAL: f32 = (ROOK_WORTH * 2 + BISHOP_WORTH * 2) as f32; -// TODO: still tweaking these :`D -pub const DOUBLED_PAWN_PENALTY: i32 = 35; -pub const ISOLATED_PAWN_PENALTY: i32 = 20; -pub const PASSED_PAWN_BOOST: [i32; 8] = [0, 15, 15, 30, 50, 90, 150, 0]; +pub const DOUBLED_PAWN_PENALTY: i32 = 35; // TODO +pub const ISOLATED_PAWN_PENALTY: i32 = 20; // TODO +pub const PASSED_PAWN_BOOST: [i32; 8] = [0, 15, 15, 30, 50, 90, 150, 0]; // TODO + +#[derive(Copy, Clone)] +pub struct BoardState { + pub castling_rights: u8, + pub fifty_move_counter: u8, + pub attacked_squares: [Option; 2], +} + +impl BoardState { + pub fn new(castling_rights: u8, fifty_move_counter: u8) -> Self { + Self { + castling_rights, + fifty_move_counter, + attacked_squares: [None, None], + } + } +} pub struct Board { pub precalculated_move_data: PrecalculatedMoveData, - pub piece_bitboards: [u64; BITBOARD_COUNT], + pub piece_bitboards: [u64; PIECE_COUNT], pub color_bitboards: [u64; 2], - pub attacked_squares_bitboards: [u64; 2], - - pub castling_rights: ValueHolder, - pub fifty_move_draw: ValueHolder, pub en_passant_file: usize, pub white_to_move: bool, - pub total_material_without_pawns: i32, + pub total_material_without_pawns: [i32; 2], pub zobrist: Zobrist, pub moves: Vec, - attacked_squares_calculated: [bool; 2], + // TODO: smaller version of the transposition table for evaluation cache? + + pub board_state: ValueHolder, } impl Board { @@ -49,29 +62,25 @@ impl Board { if fen[2].contains('q') { castling_rights ^= BLACK_CASTLE_LONG; } if fen[2].contains('k') { castling_rights ^= BLACK_CASTLE_SHORT; } - let fifty_move_draw = fen[4].parse::().unwrap_or(0); + let fifty_move_counter = fen[4].parse::().unwrap_or(0); let mut board = Self { precalculated_move_data: PrecalculatedMoveData::calculate(), - piece_bitboards: [0; BITBOARD_COUNT], + piece_bitboards: [0; PIECE_COUNT], color_bitboards: [0; 2], - attacked_squares_bitboards: [0; 2], - - castling_rights: ValueHolder::new(castling_rights), - fifty_move_draw: ValueHolder::new(fifty_move_draw), en_passant_file: 0, // This isn't implemented at all // en_passant_file: if fen[3] == "-" { 0 } else { (coordinate_to_index(fen[3]) % 8) + 1 }, white_to_move: fen[1] == "w", - total_material_without_pawns: 0, + total_material_without_pawns: [0, 0], zobrist: Zobrist::default(), moves: vec![], - attacked_squares_calculated: [false; 2], + board_state: ValueHolder::new(BoardState::new(castling_rights, fifty_move_counter)), }; let piece_rows = fen[0].split('/').collect::>(); @@ -88,22 +97,19 @@ impl Board { board.color_bitboards[is_piece_white(piece) as usize] |= 1 << i; i += 1; + let piece_is_white = is_piece_white(piece); let piece_type = get_piece_type(piece); if piece_type != PAWN && piece_type != KING { - let piece_worth = get_base_worth_of_piece(piece); - board.total_material_without_pawns += piece_worth; + let piece_worth = BASE_WORTHS_OF_PIECE_TYPE[piece_type]; + board.total_material_without_pawns[piece_is_white as usize] += piece_worth; } } } } - // This has to be done after the board is setup (Duh) - let mut zobrist = Zobrist::generate(); - zobrist.generate_initial_key(&mut board); - board.zobrist = zobrist; - + board.zobrist = Zobrist::generate(&mut board); board.calculate_attacked_squares(); board @@ -114,9 +120,14 @@ impl Board { self.calculate_attacked_squares_for_color(1); } + pub fn get_attacked_squares_for_color(&mut self, color: usize) -> u64 { + self.calculate_attacked_squares_for_color(color); + self.board_state.current.attacked_squares[color].unwrap() + } + // This is SLOOOOOOOOOOOOOWWWWWWW :[ pub fn calculate_attacked_squares_for_color(&mut self, color: usize) { - if self.attacked_squares_calculated[color] { + if self.board_state.current.attacked_squares[color].is_some() { return; } @@ -141,8 +152,7 @@ impl Board { } } - self.attacked_squares_bitboards[color] = attacked_squares; - self.attacked_squares_calculated[color] = true; + self.board_state.current.attacked_squares[color] = Some(attacked_squares); } pub fn print(&self) { @@ -174,8 +184,8 @@ impl Board { println!("{}", output); } - pub fn print_bitboards(&self) { - for piece in 0..BITBOARD_COUNT { + pub fn print_bitboards(&mut self) { + for piece in 0..PIECE_COUNT { let c = piece_to_char(piece); print_bitboard( &format!("{}", c), @@ -190,8 +200,11 @@ impl Board { print_bitboard("Black pieces", "1".bold().italic().white().on_black(), self.color_bitboards[0]); print_bitboard("White pieces", "1".bold().italic().normal().on_white(), self.color_bitboards[1]); - print_bitboard("Black attacked squares", "1".bold().italic().white().on_black(), self.attacked_squares_bitboards[0]); - print_bitboard("White attacked squares", "1".bold().italic().normal().on_white(), self.attacked_squares_bitboards[1]); + + let black_attacked_squares = self.get_attacked_squares_for_color(0); + let white_attacked_squares = self.get_attacked_squares_for_color(1); + print_bitboard("Black attacked squares", "1".bold().italic().white().on_black(), black_attacked_squares); + print_bitboard("White attacked squares", "1".bold().italic().normal().on_white(), white_attacked_squares); } pub fn get_last_move(&self) -> MoveData { @@ -224,45 +237,42 @@ impl Board { pub fn play_move(&mut self, data: MoveData) -> bool { let promoting = PROMOTABLE.contains(&data.flag); if self.white_to_move == is_piece_white(self.get_piece(data.from)) { - let legal_moves = self.get_legal_moves_for_piece(data.from, false); - for m in legal_moves { + let moves = self.get_moves_for_piece(data.from, false); + for m in moves { if (!promoting || data.flag == m.flag) && m.from == data.from - && m.to == data.to { - self.make_move(m); + && m.to == data.to + && self.make_move(m) { return true; } } } - println!("Illegal move"); + println!("Illegal move: {}", data.to_coordinates()); false } - pub fn make_move(&mut self, data: MoveData) { - let piece_color = is_piece_white(data.piece as usize) as usize; - let other_color = !is_piece_white(data.piece as usize) as usize; - - // if data.piece >= NO_PIECE as u8 { - // println!("Illegal piece! Move: {:#?}", data); - // return; - // } + pub fn make_move(&mut self, data: MoveData) -> bool { + let piece_is_white = is_piece_white(data.piece as usize); + let piece_color = piece_is_white as usize; + let other_color = (!piece_is_white) as usize; self.piece_bitboards[data.piece as usize] ^= 1 << data.from; if !PROMOTABLE.contains(&data.flag) { self.piece_bitboards[data.piece as usize] ^= 1 << data.to; } else { - self.piece_bitboards[build_piece(piece_color == 1, data.flag as usize)] ^= 1 << data.to; - self.total_material_without_pawns += get_base_worth_of_piece(data.flag as usize); + self.piece_bitboards[build_piece(piece_is_white, data.flag as usize)] ^= 1 << data.to; + self.total_material_without_pawns[piece_color] += BASE_WORTHS_OF_PIECE_TYPE[data.flag as usize]; } self.color_bitboards[piece_color] ^= 1 << data.from; self.color_bitboards[piece_color] ^= 1 << data.to; if data.capture != NO_PIECE as u8 { - if get_piece_type(data.capture as usize) != PAWN { - self.total_material_without_pawns -= get_base_worth_of_piece(data.capture as usize); + let capture_type = get_piece_type(data.capture as usize); + if capture_type != PAWN { + self.total_material_without_pawns[other_color] -= BASE_WORTHS_OF_PIECE_TYPE[capture_type]; } if data.flag == EN_PASSANT_FLAG { @@ -282,7 +292,7 @@ impl Board { // I dunno if there's a better way to do this :/ if data.piece == WHITE_KING as u8 { - self.castling_rights.current &= !ALL_WHITE_CASTLING_RIGHTS; + self.board_state.current.castling_rights &= !ALL_WHITE_CASTLING_RIGHTS; if data.flag == SHORT_CASTLE_FLAG { self.piece_bitboards[WHITE_ROOK] ^= 1 << 63; @@ -298,7 +308,7 @@ impl Board { self.color_bitboards[1] ^= 1 << 59; } } else if data.piece == BLACK_KING as u8 { - self.castling_rights.current &= !ALL_BLACK_CASTLING_RIGHTS; + self.board_state.current.castling_rights &= !ALL_BLACK_CASTLING_RIGHTS; if data.flag == SHORT_CASTLE_FLAG { self.piece_bitboards[BLACK_ROOK] ^= 1 << 7; @@ -315,47 +325,53 @@ impl Board { } } + // This is so ugly :`( if data.from == 0 { - self.castling_rights.current &= !BLACK_CASTLE_LONG; + self.board_state.current.castling_rights &= !BLACK_CASTLE_LONG; } else if data.from == 7 { - self.castling_rights.current &= !BLACK_CASTLE_SHORT; + self.board_state.current.castling_rights &= !BLACK_CASTLE_SHORT; } else if data.from == 56 { - self.castling_rights.current &= !WHITE_CASTLE_LONG; + self.board_state.current.castling_rights &= !WHITE_CASTLE_LONG; } else if data.from == 63 { - self.castling_rights.current &= !WHITE_CASTLE_SHORT; + self.board_state.current.castling_rights &= !WHITE_CASTLE_SHORT; } if data.to == 0 { - self.castling_rights.current &= !BLACK_CASTLE_LONG; + self.board_state.current.castling_rights &= !BLACK_CASTLE_LONG; } else if data.to == 7 { - self.castling_rights.current &= !BLACK_CASTLE_SHORT; + self.board_state.current.castling_rights &= !BLACK_CASTLE_SHORT; } else if data.to == 56 { - self.castling_rights.current &= !WHITE_CASTLE_LONG; + self.board_state.current.castling_rights &= !WHITE_CASTLE_LONG; } else if data.to == 63 { - self.castling_rights.current &= !WHITE_CASTLE_SHORT; + self.board_state.current.castling_rights &= !WHITE_CASTLE_SHORT; } - if data.capture == NO_PIECE as u8 - && get_piece_type(data.piece as usize) != PAWN { - self.fifty_move_draw.current += 1; + if data.capture != NO_PIECE as u8 + || get_piece_type(data.piece as usize) == PAWN { + self.board_state.current.fifty_move_counter = 0; } else { - self.fifty_move_draw.current = 0; + self.board_state.current.fifty_move_counter += 1; } - self.fifty_move_draw.push(); - self.castling_rights.push(); + self.board_state.current.attacked_squares = [None; 2]; + self.board_state.push(); self.zobrist.make_move( data, self.get_last_move(), - self.castling_rights.current, - self.castling_rights.history[self.castling_rights.index - 1], + self.board_state.current.castling_rights, + self.board_state.history[self.board_state.index - 1].castling_rights, ); - self.attacked_squares_calculated = [false; 2]; - self.moves.push(data); self.white_to_move = !self.white_to_move; + + if self.king_in_check(!self.white_to_move) { + self.undo_last_move(); + return false; + } + + true } pub fn undo_last_move(&mut self) -> bool { @@ -364,24 +380,26 @@ impl Board { } let last_move = self.moves.pop().unwrap(); - let piece_color = is_piece_white(last_move.piece as usize) as usize; - let other_color = !is_piece_white(last_move.piece as usize) as usize; + let piece_is_white = is_piece_white(last_move.piece as usize); + let piece_color = piece_is_white as usize; + let other_color = (!piece_is_white) as usize; self.piece_bitboards[last_move.piece as usize] ^= 1 << last_move.from; if !PROMOTABLE.contains(&last_move.flag) { self.piece_bitboards[last_move.piece as usize] ^= 1 << last_move.to; } else { - self.piece_bitboards[build_piece(piece_color == 1, last_move.flag as usize)] ^= 1 << last_move.to; - self.total_material_without_pawns -= get_base_worth_of_piece(last_move.flag as usize); + self.piece_bitboards[build_piece(piece_is_white, last_move.flag as usize)] ^= 1 << last_move.to; + self.total_material_without_pawns[piece_color] -= BASE_WORTHS_OF_PIECE_TYPE[last_move.flag as usize]; } self.color_bitboards[piece_color] ^= 1 << last_move.from; self.color_bitboards[piece_color] ^= 1 << last_move.to; if last_move.capture != NO_PIECE as u8 { - if get_piece_type(last_move.capture as usize) != PAWN { - self.total_material_without_pawns += get_base_worth_of_piece(last_move.capture as usize); + let capture_type = get_piece_type(last_move.capture as usize); + if capture_type != PAWN { + self.total_material_without_pawns[other_color] += BASE_WORTHS_OF_PIECE_TYPE[capture_type]; } if last_move.flag == EN_PASSANT_FLAG { @@ -398,43 +416,38 @@ impl Board { self.piece_bitboards[last_move.capture as usize] ^= 1 << last_move.to; self.color_bitboards[other_color] ^= 1 << last_move.to; } - } else { - if last_move.flag == SHORT_CASTLE_FLAG { - if piece_color == 1 { - self.piece_bitboards[WHITE_ROOK] ^= 1 << 63; - self.piece_bitboards[WHITE_ROOK] ^= 1 << 61; + } else if last_move.flag == SHORT_CASTLE_FLAG { + if piece_color == 1 { + self.piece_bitboards[WHITE_ROOK] ^= 1 << 63; + self.piece_bitboards[WHITE_ROOK] ^= 1 << 61; - self.color_bitboards[1] ^= 1 << 63; - self.color_bitboards[1] ^= 1 << 61; - } else { - self.piece_bitboards[BLACK_ROOK] ^= 1 << 7; - self.piece_bitboards[BLACK_ROOK] ^= 1 << 5; + self.color_bitboards[1] ^= 1 << 63; + self.color_bitboards[1] ^= 1 << 61; + } else { + self.piece_bitboards[BLACK_ROOK] ^= 1 << 7; + self.piece_bitboards[BLACK_ROOK] ^= 1 << 5; - self.color_bitboards[0] ^= 1 << 7; - self.color_bitboards[0] ^= 1 << 5; - } - } else if last_move.flag == LONG_CASTLE_FLAG { - if piece_color == 1 { - self.piece_bitboards[WHITE_ROOK] ^= 1 << 56; - self.piece_bitboards[WHITE_ROOK] ^= 1 << 59; + self.color_bitboards[0] ^= 1 << 7; + self.color_bitboards[0] ^= 1 << 5; + } + } else if last_move.flag == LONG_CASTLE_FLAG { + if piece_color == 1 { + self.piece_bitboards[WHITE_ROOK] ^= 1 << 56; + self.piece_bitboards[WHITE_ROOK] ^= 1 << 59; - self.color_bitboards[1] ^= 1 << 56; - self.color_bitboards[1] ^= 1 << 59; - } else { - self.piece_bitboards[BLACK_ROOK] ^= 1; // << 0 - self.piece_bitboards[BLACK_ROOK] ^= 1 << 3; + self.color_bitboards[1] ^= 1 << 56; + self.color_bitboards[1] ^= 1 << 59; + } else { + self.piece_bitboards[BLACK_ROOK] ^= 1; // << 0 + self.piece_bitboards[BLACK_ROOK] ^= 1 << 3; - self.color_bitboards[0] ^= 1; - self.color_bitboards[0] ^= 1 << 3; - } + self.color_bitboards[0] ^= 1; + self.color_bitboards[0] ^= 1 << 3; } } - self.fifty_move_draw.pop(); - self.castling_rights.pop(); - self.zobrist.pop(); - - self.attacked_squares_calculated = [false; 2]; + self.board_state.pop(); + self.zobrist.key.pop(); self.white_to_move = !self.white_to_move; @@ -442,11 +455,11 @@ impl Board { } pub fn king_in_check(&mut self, king_is_white: bool) -> bool { - self.calculate_attacked_squares_for_color((!king_is_white) as usize); - self.piece_bitboards[build_piece(king_is_white, KING)] & self.attacked_squares_bitboards[(!king_is_white) as usize] != 0 + let attacked_squares = self.get_attacked_squares_for_color((!king_is_white) as usize); + self.piece_bitboards[build_piece(king_is_white, KING)] & attacked_squares != 0 } - pub fn get_legal_moves_for_color(&mut self, white_pieces: bool, only_captures: bool) -> Vec { + pub fn get_pseudo_legal_moves_for_color(&mut self, white_pieces: bool, only_captures: bool) -> Vec { let mut result = vec![]; let pieces = if white_pieces { @@ -460,14 +473,24 @@ impl Board { while bitboard != 0 { let piece_index = pop_lsb(&mut bitboard); - result.extend(self.get_legal_moves_for_piece(piece_index, only_captures)); + result.extend(self.get_moves_for_piece(piece_index, only_captures)); } } + // for i in (0..result.len()).rev() { + // self.make_move(result[i]); + + // if self.king_in_check(!self.white_to_move) { + // result.remove(i); + // } + + // self.undo_last_move(); + // } + result } - pub fn get_legal_moves_for_piece(&mut self, piece_index: u8, only_captures: bool) -> Vec { + pub fn get_moves_for_piece(&mut self, piece_index: u8, only_captures: bool) -> Vec { let mut result = vec![]; let piece = self.get_piece(piece_index); @@ -842,22 +865,6 @@ impl Board { _ => {} } - for i in (0..result.len()).rev() { - let data = result[i]; - - // if data.piece == NO_PIECE as u8 { - // println!("Illegal move found! {:#?} on piece: {}, and index: {}, captures only: {}", data, piece_type, piece_index, only_captures); - // } - - self.make_move(data); - - if self.king_in_check(!self.white_to_move) { - result.remove(i); - } - - self.undo_last_move(); - } - result } @@ -885,7 +892,7 @@ impl Board { // Returns a value between 0.0 and 1.0 to reflect whether you're in an endgame or not // the closer to 1.0, the more of an endgame it is pub fn endgame_multiplier(&self) -> f32 { - (1.5 - self.total_material_without_pawns as f32 * (0.9 / MAX_ENDGAME_MATERIAL as f32)).clamp(0.0, 1.0) + (1.5 - self.total_material_without_pawns.iter().sum::() as f32 * (0.9 / MAX_ENDGAME_MATERIAL)).clamp(0.0, 1.0) // (1.0 - self.total_material_without_pawns as f32 * (1.0 / MAX_ENDGAME_MATERIAL)).clamp(0.0, 1.0) } @@ -922,7 +929,7 @@ impl Board { if self.precalculated_move_data.squares_ahead_of_pawn[1][piece_index] & self.piece_bitboards[BLACK_PAWN] == 0 && self.precalculated_move_data.file_in_front_of_pawn[1][piece_index] & self.piece_bitboards[WHITE_PAWN] == 0 { // Passed pawn - white_pawn_evaluation += PASSED_PAWN_BOOST[8 - piece_index / 8]; + white_pawn_evaluation += PASSED_PAWN_BOOST[7 - piece_index / 8]; } } } else { @@ -946,56 +953,67 @@ impl Board { } } - let pawn_evaluation_multiplier = (endgame + 0.3).clamp(0.3, 1.0); + let pawn_evaluation_multiplier = (endgame + 0.3).clamp(0.3, 1.0); // TODO white_pawn_evaluation = (white_pawn_evaluation as f32 * pawn_evaluation_multiplier) as i32; black_pawn_evaluation = (black_pawn_evaluation as f32 * pawn_evaluation_multiplier) as i32; - self.calculate_attacked_squares(); + let white_attacks_bitboard = self.get_attacked_squares_for_color(1); + let black_attacks_bitboard = self.get_attacked_squares_for_color(0); - let white_attacked_squares = self.attacked_squares_bitboards[1].count_ones() as i32; - let black_attacked_squares = self.attacked_squares_bitboards[0].count_ones() as i32; + // Taking the sqrt of this made it worse + let white_attacks_score = white_attacks_bitboard.count_ones() as i32 * 10; + let black_attacks_score = black_attacks_bitboard.count_ones() as i32 * 10; - let white_king_index = pop_lsb(&mut (self.piece_bitboards[WHITE_KING].clone())) as usize; - let black_king_index = pop_lsb(&mut (self.piece_bitboards[BLACK_KING].clone())) as usize; + let white_king_index = get_lsb(self.piece_bitboards[WHITE_KING]) as usize; + let black_king_index = get_lsb(self.piece_bitboards[BLACK_KING]) as usize; - let weak_squares_around_white_king = (( + // TODO: weak squares, weak lines, or none? + // TODO: Or count how many friendly pieces are around the king? + let white_king_weakness_penalty = (( self.precalculated_move_data.king_attacks[white_king_index] - & self.attacked_squares_bitboards[0] - ).count_ones() as f32 * (1.0 - endgame)) as i32; + & black_attacks_bitboard + ).count_ones() as f32 * (1.0 - endgame)) as i32 * 20; - let weak_squares_around_black_king = (( + let black_king_weakness_penalty = (( self.precalculated_move_data.king_attacks[black_king_index] - & self.attacked_squares_bitboards[1] - ).count_ones() as f32 * (1.0 - endgame)) as i32; + & white_attacks_bitboard + ).count_ones() as f32 * (1.0 - endgame)) as i32 * 20; - ((white_material + white_attacked_squares * 10 - weak_squares_around_white_king * 20 + white_pawn_evaluation) - - (black_material + black_attacked_squares * 10 - weak_squares_around_black_king * 20 + black_pawn_evaluation)) * self.perspective() + // let weak_lines_from_white_king = (self.calculate_queen_attack_bitboard(white_king_index).count_ones() as f32 * (1.0 - endgame)) as i32; + // let weak_lines_from_black_king = (self.calculate_queen_attack_bitboard(black_king_index).count_ones() as f32 * (1.0 - endgame)) as i32; + + // TODO: a small boost for having the bishop pair? + + // TODO: rooks on open lines + + ((white_material + white_attacks_score - white_king_weakness_penalty + white_pawn_evaluation) + - (black_material + black_attacks_score - black_king_weakness_penalty + black_pawn_evaluation)) * self.perspective() } pub fn can_short_castle(&mut self, white: bool) -> bool { // self.king_in_check calculates attacked squares !self.king_in_check(white) - && self.castling_rights.current & SHORT_CASTLING_RIGHTS[white as usize] != 0 - && (self.occupied_bitboard() | self.attacked_squares_bitboards[(!white) as usize]) & SHORT_CASTLE_MASK[white as usize] == 0 + && self.board_state.current.castling_rights & SHORT_CASTLING_RIGHTS[white as usize] != 0 + && (self.occupied_bitboard() | self.board_state.current.attacked_squares[(!white) as usize].unwrap()) & SHORT_CASTLE_MASK[white as usize] == 0 } pub fn can_long_castle(&mut self, white: bool) -> bool { let occupied = self.occupied_bitboard(); !self.king_in_check(white) - && self.castling_rights.current & LONG_CASTLING_RIGHTS[white as usize] != 0 + && self.board_state.current.castling_rights & LONG_CASTLING_RIGHTS[white as usize] != 0 && EXTRA_LONG_CASTLE_SQUARE_CHECK[white as usize] & occupied == 0 - && (occupied | self.attacked_squares_bitboards[(!white) as usize]) & LONG_CASTLE_MASK[white as usize] == 0 + && (occupied | self.board_state.current.attacked_squares[(!white) as usize].unwrap()) & LONG_CASTLE_MASK[white as usize] == 0 } pub fn insufficient_checkmating_material(&self) -> bool { - self.total_material_without_pawns < ROOK_WORTH + self.total_material_without_pawns.iter().sum::() < ROOK_WORTH && self.piece_bitboards[WHITE_PAWN] == 0 && self.piece_bitboards[BLACK_PAWN] == 0 } + // Must be called when not in check! pub fn try_null_move(&mut self) -> bool { - if self.king_in_check(self.white_to_move) - || self.king_in_check(!self.white_to_move) { + if self.get_last_move() == NULL_MOVE { return false; } @@ -1003,12 +1021,83 @@ impl Board { self.zobrist.make_null_move(); self.moves.push(NULL_MOVE); + self.board_state.current.fifty_move_counter = 0; + self.board_state.push(); + true } pub fn undo_null_move(&mut self) { self.white_to_move = !self.white_to_move; - self.zobrist.pop(); + self.zobrist.key.pop(); self.moves.pop(); + + self.board_state.pop(); + } + + // Counting only one repetition as a draw seems to perform better than detecting a threefold repetition + pub fn is_repetition(&self) -> bool { + // Before the third move, it's impossible to have a repetition + if self.zobrist.key.index < 2 { + return false; + } + + let lookback = self.zobrist.key.index - self.board_state.current.fifty_move_counter as usize; + let mut i = self.zobrist.key.index - 2; + + while i >= lookback { + if self.zobrist.key.history[i] == self.zobrist.key.current { + return true; + } + + if i < 2 { + break; + } + + i -= 2; + } + + false + } + + pub fn is_draw(&self) -> bool { + self.board_state.current.fifty_move_counter >= 100 + || self.insufficient_checkmating_material() + || self.is_repetition() + } + + // This isn't used anywhere and I haven't tested it, so it might be bugged + pub fn square_is_attacked_by_color(&self, square_bitboard: u64, white_pieces: bool) -> bool { + let color = white_pieces as usize; + + let pieces = + if white_pieces { + WHITE_PAWN..=WHITE_KING + } else { + BLACK_PAWN..=BLACK_KING + }; + + for piece in pieces { + let piece_type = get_piece_type(piece); + let mut bitboard = self.piece_bitboards[piece]; + + while bitboard != 0 { + let piece_index = pop_lsb(&mut bitboard) as usize; + + if (match piece_type { + PAWN => self.precalculated_move_data.pawn_attacks[color][piece_index], + KNIGHT => self.precalculated_move_data.knight_attacks[piece_index], + BISHOP => self.calculate_bishop_attack_bitboard(piece_index), + ROOK => self.calculate_rook_attack_bitboard(piece_index), + QUEEN => self.calculate_queen_attack_bitboard(piece_index), + KING => self.precalculated_move_data.king_attacks[piece_index], + _ => 0, + }) & square_bitboard != 0 { + return true; + } + } + } + + false } } \ No newline at end of file diff --git a/src/bot.rs b/src/bot.rs index ba51174..99e2281 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,23 +1,15 @@ use crate::STARTING_FEN; use crate::piece_square_tables::{PAWN_WORTH, QUEEN_WORTH}; use crate::pieces::{PAWN, PROMOTABLE, NO_PIECE}; -use crate::utils::{CHECKMATE_EVAL, evaluation_is_mate, moves_ply_from_mate}; +use crate::utils::{CHECKMATE_EVAL, evaluation_is_mate, ply_from_mate}; use std::time::Instant; use crate::move_sorter::MoveSorter; -use crate::transposition_table::{TranspositionTable, NodeType}; +use crate::transposition_table::{TranspositionTable, EvalBound}; use crate::move_data::{MoveData, NULL_MOVE}; use crate::opening_book::OpeningBook; use crate::Board; -pub const MAX_SEARCH_EXTENSIONS: u8 = 16; -pub const FUTILITY_PRUNING_THESHOLD_PER_PLY: i32 = 60; -pub const RAZORING_THRESHOLD_PER_PLY: i32 = 300; - -pub const PERCENT_OF_TIME_TO_USE_BEFORE_6_FULL_MOVES: f32 = 0.025; // 2.5% -pub const PERCENT_OF_TIME_TO_USE_AFTER_6_FULL_MOVES: f32 = 0.07; // 7% - -pub const MIN_TIME_PER_MOVE: f32 = 0.25; // seconds -pub const MAX_TIME_PER_MOVE: f32 = 20.0; +pub const MAX_SEARCH_EXTENSIONS: u8 = 20; #[derive(Clone, Debug)] pub struct BotConfig { @@ -25,16 +17,20 @@ pub struct BotConfig { pub debug_output: bool, pub opening_book: bool, pub time_management: bool, + pub hash_size: usize, } impl BotConfig { pub fn from_args(args: Vec) -> Self { let _true = "true".to_string(); + let _false = "false".to_string(); + Self { // This is so ugly lol fen: Self::get_arg_value(&args, "fen").unwrap_or(STARTING_FEN.to_string()), debug_output: Self::get_arg_value(&args, "debug_output").unwrap_or(_true.clone()) == _true, - opening_book: Self::get_arg_value(&args, "opening_book").unwrap_or(_true.clone()) == _true, + opening_book: Self::get_arg_value(&args, "opening_book").unwrap_or(_false.clone()) == _true, time_management: Self::get_arg_value(&args, "time_management").unwrap_or(_true.clone()) == _true, + hash_size: (Self::get_arg_value(&args, "hash_size").unwrap_or("256".to_string())).parse::().unwrap_or(256), } } @@ -65,22 +61,18 @@ pub struct Bot { pub best_move: MoveData, best_move_this_iteration: MoveData, - best_move_depth_searched_at: u8, evaluation: i32, evaluation_this_iteration: i32, positions_searched: u128, quiescence_searched: u128, - transposition_hits: u128, } impl Bot { pub fn new(config: BotConfig) -> Self { - let in_opening_book = config.opening_book; - Self { - config, + config: config.clone(), time_to_think: 0.0, think_timer: Instant::now(), @@ -88,21 +80,19 @@ impl Bot { searched_one_move: false, opening_book: OpeningBook::create(), - in_opening_book, + in_opening_book: config.opening_book, move_sorter: MoveSorter::new(), - transposition_table: TranspositionTable::empty(), + transposition_table: TranspositionTable::empty(config.hash_size), best_move: NULL_MOVE, best_move_this_iteration: NULL_MOVE, - best_move_depth_searched_at: 0, evaluation: 0, evaluation_this_iteration: 0, positions_searched: 0, quiescence_searched: 0, - transposition_hits: 0, } } @@ -112,7 +102,7 @@ impl Bot { } } - pub fn start(&mut self, board: &mut Board, moves: String, my_time: f32, depth_to_search: u8) { + pub fn start(&mut self, board: &mut Board, moves: String, my_time: f32, depth: u8) { if self.in_opening_book { let opening_move = self.opening_book.get_opening_move(moves); if opening_move == NULL_MOVE { @@ -124,55 +114,51 @@ impl Bot { } self.time_to_think = - if my_time == 0.0 { // This means we're in a "go depth X" command - 0.0 - } else if self.config.time_management { + if self.config.time_management + && my_time > 0.0 { let time_percentage = if board.moves.len() / 2 <= 6 { - PERCENT_OF_TIME_TO_USE_BEFORE_6_FULL_MOVES + 0.025 } else { - PERCENT_OF_TIME_TO_USE_AFTER_6_FULL_MOVES + 0.07 }; - (my_time * time_percentage).clamp(MIN_TIME_PER_MOVE, MAX_TIME_PER_MOVE) + (my_time * time_percentage).clamp(0.05, 30.0) } else { my_time }; self.search_cancelled = false; - let last_evaluation = self.evaluation; - self.best_move = NULL_MOVE; - self.evaluation = 0; self.positions_searched = 0; self.quiescence_searched = 0; - self.transposition_hits = 0; + self.transposition_table.hits = 0; self.move_sorter.clear(); + // TODO: tweak this let mut window = 40; self.think_timer = Instant::now(); - for depth in 1..=depth_to_search { + for current_depth in 1..=depth { self.searched_one_move = false; self.best_move_this_iteration = NULL_MOVE; self.evaluation_this_iteration = 0; - loop { - let (alpha, beta) = (last_evaluation - window, last_evaluation + window); + let (alpha, beta) = (self.evaluation - window, self.evaluation + window); - let evaluation = self.alpha_beta_search(board, 0, depth, alpha, beta, 0); + let evaluation = self.alpha_beta_search(board, current_depth, 0, alpha, beta, 0); - if alpha < evaluation && evaluation < beta { + if evaluation > alpha + && evaluation < beta { break; } window *= 4; } - if !self.search_cancelled || self.searched_one_move { self.best_move = self.best_move_this_iteration; @@ -180,19 +166,19 @@ impl Bot { } self.println(format!("Depth: {}, Window: {}, Evaluation: {}, Best move: {}, Positions searched: {} + Quiescence positions searched: {} = {}, Transposition Hits: {}", - depth, + current_depth, window, self.evaluation * board.perspective(), self.best_move.to_coordinates(), self.positions_searched, self.quiescence_searched, self.positions_searched + self.quiescence_searched, - self.transposition_hits, + self.transposition_table.hits, )); if evaluation_is_mate(self.evaluation) { - let moves_until_mate = moves_ply_from_mate(self.evaluation); - if moves_until_mate <= depth { + let moves_until_mate = ply_from_mate(self.evaluation); + if moves_until_mate <= current_depth { self.println(format!("Mate found in {}", (moves_until_mate as f32 * 0.5).ceil())); break; } @@ -204,9 +190,20 @@ impl Bot { } } + if self.best_move == NULL_MOVE { + let legal_moves = board.get_pseudo_legal_moves_for_color(board.white_to_move, false); + for m in legal_moves { + if board.make_move(m) { + board.undo_last_move(); // Technically not necessary, because all moves get undone upon receiving the "position" command, but makes me happy :> + self.best_move = m; + break; + } + } + self.println("Failed to find a move in time, defaulting to first legal move :(".to_string()); + } + self.println(format!("{} seconds", self.think_timer.elapsed().as_secs_f32())); - self.transposition_table.update(); if self.config.debug_output { self.transposition_table.print_size(); } @@ -220,52 +217,94 @@ impl Bot { fn alpha_beta_search( &mut self, board: &mut Board, - depth: u8, - mut depth_left: u8, + mut depth: u8, + ply: u8, mut alpha: i32, beta: i32, - number_of_extensions: u8, + total_extensions: u8, ) -> i32 { + // TODO: try moving this into the move loop and break instead of return 0? if self.should_cancel_search() { return 0; } - self.positions_searched += 1; + if ply > 0 { + self.positions_searched += 1; - if depth > 0 - && (board.fifty_move_draw.current >= 50 - || board.insufficient_checkmating_material() - || board.zobrist.is_threefold_repetition()) { - // Should I use this to discourage making a draw in a winning position? - // return -self.quiescence_search(board, alpha, beta); - return 0; + if board.is_draw() { + // Should I use this to discourage making a draw in a winning position? + // return -self.quiescence_search(board, alpha, beta); + return 0; + } + + // Mate Distance Pruning + let mate_value = CHECKMATE_EVAL - ply as i32; + let alpha = i32::max(alpha, -mate_value); + let beta = i32::min(beta, mate_value - 1); + if alpha >= beta { + return alpha; + } } - if let Some(data) = self.transposition_table.lookup(board.zobrist.key, depth_left, depth, alpha, beta) { - self.positions_searched -= 1; - self.transposition_hits += 1; + let (tt_eval, hash_move) = self.transposition_table.lookup(board.zobrist.key.current, ply, depth, alpha, beta); - if depth == 0 { - self.best_move_this_iteration = data.best_move; - self.best_move_depth_searched_at = data.depth_left; - self.evaluation_this_iteration = data.evaluation; + // We don't really want to return from the root node, because if a hash collision occurs (although very rare) + // It will return an illegal move + if ply > 0 { + if let Some(tt_eval) = tt_eval { + return tt_eval; } - return data.evaluation; + // Internal Iterative Reductions + if depth > 1 + && hash_move.is_none() { + depth -= 1; + } } - let is_pv = alpha != beta - 1; + // This detects a null / zero window search, which is used in non PV nodes + // This will also never be true if ply == 0 because the bounds will never be zero at ply 0 + let not_pv = alpha == beta - 1; + let in_check = board.king_in_check(board.white_to_move); - if !is_pv + if not_pv && depth > 0 - && depth_left > 0 - && board.get_last_move().capture == NO_PIECE as u8 - && !board.king_in_check(board.white_to_move) { + && !in_check + && !evaluation_is_mate(alpha) + && !evaluation_is_mate(beta) { + let static_eval = board.evaluate(); + + // Reverse Futility Pruning + if depth < 8 // TODO: mess around with this + && static_eval - (55 + 50 * (depth as i32 - 1).pow(2)) >= beta { // TODO: continue tweaking this + return beta; + } + + // Strelka Razoring (Slightly modified) + // if depth < 4 { + // let razoring_threshold = static_eval + + // if depth == 1 { + // 150 + // } else { + // 350 + // }; + + // if razoring_threshold < beta { + // let evaluation = self.quiescence_search(board, alpha, beta); + // if evaluation < beta { + // return i32::max(evaluation, razoring_threshold); + // } + // } + // } + // Null Move Pruning - if depth_left >= 3 + if depth > 1 + && static_eval >= beta // Fruit used || depth > X here, but I haven't found great results with that + && board.total_material_without_pawns[board.white_to_move as usize] > 0 + && board.get_last_move().capture == NO_PIECE as u8 // Moving the check from the above check to only NMP was a decent improvement && board.try_null_move() { - // let reduction = 3 - (depth_left - 3) / 2; // This didn't work at all lol - let evaluation = -self.alpha_beta_search(board, depth + 1, depth_left - 3, -beta, -beta + 1, number_of_extensions); + let reduction = 3 + (depth - 2) / 3; + let evaluation = -self.alpha_beta_search(board, depth.saturating_sub(reduction), ply + 1, -beta, -beta + 1, total_extensions); board.undo_null_move(); @@ -274,85 +313,90 @@ impl Bot { } } - let static_eval = board.evaluate(); - - // Reverse Futility Pruning - if depth_left <= 4 - && static_eval - FUTILITY_PRUNING_THESHOLD_PER_PLY * (depth_left as i32) >= beta { - return static_eval; - } - // Razoring - if depth_left <= 3 - && static_eval + RAZORING_THRESHOLD_PER_PLY * (depth_left as i32) < alpha { - depth_left -= 1; + if depth < 4 // TODO: try different values for this + && static_eval + (300 + 150 * (depth as i32 - 1)) < alpha { // TODO: tweak this some more + depth -= 1; } } - if depth_left == 0 { - return self.quiescence_search(board, alpha, beta); + if depth == 0 { + return self.quiescence_search(board, alpha, beta, true); } let mut best_move_this_search = NULL_MOVE; - let mut node_type = NodeType::UpperBound; - - let legal_moves = board.get_legal_moves_for_color(board.white_to_move, false); - if legal_moves.is_empty() { - if board.king_in_check(board.white_to_move) { - let mate_score = CHECKMATE_EVAL - depth as i32; - return -mate_score; - } - return 0; - } - - let hash_move = - if depth == 0 { + // let mut eval_bound = EvalBound::UpperBound; + + let sorted_moves = self.move_sorter.sort_moves( + board.white_to_move, + board.get_pseudo_legal_moves_for_color(board.white_to_move, false), + /* + The best move is _not_ the same as the hash move, because we could have + found a new best move right before exiting the search, before tt.store gets called + */ + if ply == 0 + && self.best_move != NULL_MOVE { self.best_move - } else if let Some(data) = self.transposition_table.table.get(&board.zobrist.key) { - data.best_move } else { - NULL_MOVE - }; + hash_move.unwrap_or(NULL_MOVE) + }, + ply as usize, + ); - let sorted_moves = self.move_sorter.sort_moves(board, legal_moves, hash_move, depth); + let mut found_pv = false; - for i in 0..sorted_moves.len() { - let m = sorted_moves[i]; - board.make_move(m); + let mut legal_moves_found = 0; + for (_score, m) in sorted_moves { + if !board.make_move(m) { + continue; + } - let mut search_extension = 0; - if number_of_extensions < MAX_SEARCH_EXTENSIONS as u8 { - if board.king_in_check(board.white_to_move) { - search_extension = 1; - } else { - if m.piece == PAWN as u8 { - let rank = m.to / 8; - if rank == 1 || rank == 6 { - search_extension = 1; - } - } - } + let mut extension = 0; + if board.king_in_check(board.white_to_move) { + extension += 1; } + if m.piece == PAWN as u8 { + let rank = m.to / 8; + if rank == 1 || rank == 6 { + extension += 1; + } + } + extension = u8::min(extension, MAX_SEARCH_EXTENSIONS - total_extensions); - // Late Move Reduction / (Kind of) Principal Variation Search let mut evaluation = 0; - let mut needs_full_search = true; + let mut needs_fuller_search = true; - if search_extension == 0 - && i >= 3 - && depth_left >= 3 + // Late Move Reductions + if legal_moves_found > 3 + && depth > 1 + && extension == 0 && m.capture == NO_PIECE as u8 { - evaluation = -self.alpha_beta_search(board, depth + 1, depth_left - 1 - 1, -alpha - 1, -alpha, number_of_extensions); - needs_full_search = evaluation > alpha; - } + let mut reduction = 2; + + if found_pv { + reduction += 1; + } + + if not_pv { + reduction += 1; // TODO: + depth / 6 + } - if needs_full_search { - evaluation = -self.alpha_beta_search(board, depth + 1, depth_left - 1 + search_extension, -beta, -alpha, number_of_extensions + search_extension); + evaluation = -self.alpha_beta_search(board, depth.saturating_sub(reduction), ply + 1, -alpha - 1, -alpha, total_extensions); + needs_fuller_search = evaluation > alpha; // && evaluation < beta? } + // Principal Variation Search + if needs_fuller_search + && found_pv { + evaluation = -self.alpha_beta_search(board, depth + extension - 1, ply + 1, -alpha - 1, -alpha, total_extensions + extension); + needs_fuller_search = evaluation > alpha; // && evaluation < beta? + } + if needs_fuller_search { + evaluation = -self.alpha_beta_search(board, depth + extension - 1, ply + 1, -beta, -alpha, total_extensions + extension); + } board.undo_last_move(); @@ -361,42 +405,58 @@ impl Bot { } if evaluation >= beta { - self.transposition_table.store(board.zobrist.key, depth_left, depth, beta, m, NodeType::LowerBound); + self.transposition_table.store(board.zobrist.key.current, depth, ply, beta, m, EvalBound::LowerBound); if m.capture == NO_PIECE as u8 { - self.move_sorter.push_killer_move(m, depth); - self.move_sorter.history[m.piece as usize][m.to as usize] += (depth_left * depth_left) as i32; + self.move_sorter.add_killer_move(m, ply as usize); + self.move_sorter.history[board.white_to_move as usize][m.from as usize][m.to as usize] += (depth * depth) as i32; } return beta; } if evaluation > alpha { + found_pv = true; + best_move_this_search = m; - node_type = NodeType::Exact; + // eval_bound = EvalBound::Exact; alpha = evaluation; - if depth == 0 { + if ply == 0 { self.searched_one_move = true; self.best_move_this_iteration = best_move_this_search; - self.best_move_depth_searched_at = depth_left; self.evaluation_this_iteration = evaluation; } } + + legal_moves_found += 1; + } + + if legal_moves_found == 0 { + if in_check { + let mate_score = CHECKMATE_EVAL - ply as i32; + return -mate_score; + } + return 0; } + // I've seen a small improvement if I don't store EvalBound::UpperBound, is this normal? if best_move_this_search != NULL_MOVE { - self.transposition_table.store(board.zobrist.key, depth_left, depth, alpha, best_move_this_search, node_type); + self.transposition_table.store(board.zobrist.key.current, depth, ply, alpha, best_move_this_search, EvalBound::Exact); } alpha } - fn quiescence_search(&mut self, board: &mut Board, mut alpha: i32, beta: i32) -> i32 { + fn quiescence_search(&mut self, board: &mut Board, mut alpha: i32, beta: i32, is_root: bool) -> i32 { if self.should_cancel_search() { return 0; } + if is_root { + self.positions_searched -= 1; + } + self.quiescence_searched += 1; let evaluation = board.evaluate(); @@ -408,29 +468,32 @@ impl Bot { alpha = evaluation; } - let legal_moves = board.get_legal_moves_for_color(board.white_to_move, true); - if legal_moves.is_empty() { + let moves = board.get_pseudo_legal_moves_for_color(board.white_to_move, true); + if moves.is_empty() { return evaluation; } - // Depth is set to u8::MAX because it's only used for killer moves, and we don't need that here - let sorted_moves = self.move_sorter.sort_moves(board, legal_moves, NULL_MOVE, u8::MAX); - - for m in sorted_moves { + let sorted_moves = self.move_sorter.sort_moves(board.white_to_move, moves, NULL_MOVE, usize::MAX); + for (_score, m) in sorted_moves { // Delta Pruning if !board.king_in_check(board.white_to_move) { - let mut threshold = QUEEN_WORTH; - if PROMOTABLE.contains(&m.flag) { - threshold += QUEEN_WORTH - PAWN_WORTH; - } + let threshold = QUEEN_WORTH + + if PROMOTABLE.contains(&m.flag) { + QUEEN_WORTH - PAWN_WORTH + } else { + 0 + }; if evaluation < alpha - threshold { continue; } } - board.make_move(m); - let evaluation = -self.quiescence_search(board, -beta, -alpha); + if !board.make_move(m) { + continue; + } + + let evaluation = -self.quiescence_search(board, -beta, -alpha, false); board.undo_last_move(); if evaluation >= beta { diff --git a/src/castling_rights.rs b/src/castling_rights.rs index f08820a..be92ced 100644 --- a/src/castling_rights.rs +++ b/src/castling_rights.rs @@ -1,7 +1,7 @@ pub const WHITE_CASTLE_LONG: u8 = 0b1000; pub const WHITE_CASTLE_SHORT: u8 = 0b0100; -pub const BLACK_CASTLE_SHORT: u8 = 0b0010; -pub const BLACK_CASTLE_LONG: u8 = 0b0001; +pub const BLACK_CASTLE_LONG: u8 = 0b0010; +pub const BLACK_CASTLE_SHORT: u8 = 0b0001; pub const SHORT_CASTLING_RIGHTS: [u8; 2] = [BLACK_CASTLE_SHORT, WHITE_CASTLE_SHORT]; pub const LONG_CASTLING_RIGHTS: [u8; 2] = [BLACK_CASTLE_LONG, WHITE_CASTLE_LONG]; diff --git a/src/killer_moves.rs b/src/killer_moves.rs index 5802761..af1bfe3 100644 --- a/src/killer_moves.rs +++ b/src/killer_moves.rs @@ -2,25 +2,26 @@ use crate::move_data::{MoveData, NULL_MOVE}; #[derive(Copy, Clone)] pub struct KillerMoves { - pub a: MoveData, - pub b: MoveData, + pub moves: [MoveData; 2], } impl KillerMoves { pub fn new() -> Self { Self { - a: NULL_MOVE, - b: NULL_MOVE, + moves: [NULL_MOVE; 2], } } - pub fn push(&mut self, new_move: MoveData) { - self.b = self.a; - self.a = new_move; + pub fn add_killer_move(&mut self, new_move: MoveData) { + if self.moves[0] == new_move { + return; + } + + self.moves.rotate_right(1); + self.moves[0] = new_move; } - pub fn is_killer(&self, check: MoveData) -> bool { - check == self.a - || check == self.b + pub fn is_killer(&self, data: MoveData) -> bool { + self.moves.contains(&data) } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 6d49c8c..673c6b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,3 @@ -/* TODO -calculate my own magic numbers; currently "borrowing" Sebastian Lague's ^^ -check out pin detection to speed up check detection? -try to write a neural network to evaluate positions? :o -figure out how to implement "pondering" to think on opponent's time - -Ideas I've tried, but they didn't help, or made it play worse (Or I implemented them wrong :P) -https://www.chessprogramming.org/Futility_Pruning -https://www.chessprogramming.org/Principal_Variation_Search -https://www.chessprogramming.org/Internal_Iterative_Deepening - -Random ideas to try (from other engines and chessprogramming.org) -History reduction -https://www.chessprogramming.org/History_Leaf_Pruning -https://www.chessprogramming.org/Futility_Pruning#MoveCountBasedPruning -https://www.chessprogramming.org/Triangular_PV-Table -https://www.chessprogramming.org/Static_Exchange_Evaluation - -Some random resources I found: (Not using them right now but they could be useful) -https://analog-hors.github.io/site/magic-bitboards/ -https://web.archive.org/web/20071030220825/http://www.brucemo.com/compchess/programming/pvs.htm -*/ - #![allow(dead_code)] #![allow(unused_variables)] #![allow(unused_imports)] @@ -42,8 +19,11 @@ mod board; mod zobrist; mod perft; mod bot; +mod pv_table; mod move_sorter; +mod scored_move_list; +use crate::utils::move_str_is_valid; use crate::castling_rights::print_castling_rights; use crate::bot::{Bot, BotConfig, MAX_SEARCH_EXTENSIONS}; use crate::perft::*; @@ -55,17 +35,18 @@ use std::io; use colored::Colorize; use std::time::Instant; -pub const STARTING_FEN: &str = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; -pub const KIWIPETE_FEN: &str = "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"; -pub const TEST_POSITION_4: &str = "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1"; -pub const DRAWN_ENDGAME_FEN: &str = "8/8/8/3k4/R5p1/P5r1/4K3/8 w - - 0 1"; -pub const MATE_IN_5_FEN: &str = "4r3/7q/nb2prRp/pk1p3P/3P4/P7/1P2N1P1/1K1B1N2 w - - 0 1"; -pub const PAWN_ENDGAME_FEN: &str = "8/k7/3p4/p2P1p2/P2P1P2/8/8/K7 w - - 0 1"; -pub const ENDGAME_POSITION: &str = "8/pk4p1/2prp3/3p1p2/3P2p1/R2BP3/2P2KPP/8 w - - 8 35"; -pub const PAWN_EVAL_TESTING: &str = "4k3/p1pp4/8/4pp1P/2P4P/8/P5P1/4K3 w - - 0 1"; +pub const STARTING_FEN: &str = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; +pub const KIWIPETE_FEN: &str = "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"; +pub const TEST_POSITION_4: &str = "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1"; +pub const DRAWN_ENDGAME_FEN: &str = "8/8/8/3k4/R5p1/P5r1/4K3/8 w - - 0 1"; +pub const MATE_IN_5_FEN: &str = "4r3/7q/nb2prRp/pk1p3P/3P4/P7/1P2N1P1/1K1B1N2 w - - 0 1"; +pub const PAWN_ENDGAME_FEN: &str = "8/k7/3p4/p2P1p2/P2P1P2/8/8/K7 w - - 0 1"; +pub const ONE_PAWN_ENDGAME_FEN: &str = "8/8/1k6/8/8/1K6/1P6/8 w - - 0 1"; +pub const ENDGAME_POSITION: &str = "8/pk4p1/2prp3/3p1p2/3P2p1/R2BP3/2P2KPP/8 w - - 8 35"; +pub const PAWN_EVAL_TESTING: &str = "4k3/p1pp4/8/4pp1P/2P4P/8/P5P1/4K3 w - - 0 1"; fn main() { - let bot_config = BotConfig::from_args(std::env::args().collect::>()); + let mut bot_config = BotConfig::from_args(std::env::args().collect::>()); // let mut log = Log::none(); @@ -80,10 +61,11 @@ fn main() { io::stdin() .read_line(&mut command) - .expect("Failed to read command"); + .expect("Failed to read terminal input"); // log.write(format!("Got command: {}\n", command)); + // The length of this Vec will always be > 0 let command_split = command.trim() .split(' ') .collect::>(); @@ -92,12 +74,30 @@ fn main() { // UCI protocol "uci" => { - println!("id name Maxwell v3.0.8-2"); + println!("id name Maxwell v3.1.0"); println!("id author eboatwright"); + println!("option name Hash type spin default 256 min 0 max 4000"); println!("uciok"); } + "setoption" => { + // 0 1 2 3 4 + // setoption name