A chess library for Gleam

Implement basic minimax search and static evaluation

+186 -1
+2 -1
src/starfish.gleam
··· 3 3 import starfish/internal/board 4 4 import starfish/internal/game 5 5 import starfish/internal/move 6 + import starfish/internal/search 6 7 7 8 pub type Game = 8 9 game.Game ··· 126 127 } 127 128 128 129 pub fn search(game: Game, to_depth depth: Int) -> Result(Move(Legal), Nil) { 129 - todo 130 + search.best_move(game, depth) 130 131 } 131 132 132 133 pub fn apply_move(game: Game, move: Move(Legal)) -> Game {
+33
src/starfish/internal/evaluate.gleam
··· 1 + import gleam/dict 2 + import gleam/list 3 + import starfish/internal/board 4 + import starfish/internal/game 5 + import starfish/internal/move 6 + 7 + /// Statically evaluates a position. Does not take into account checkmate or 8 + /// stalemate, those must be accounted for beforehand. 9 + pub fn evaluate( 10 + game: game.Game, 11 + legal_moves: List(move.Move(move.Legal)), 12 + ) -> Int { 13 + evaluate_position(game) + list.length(legal_moves) 14 + } 15 + 16 + fn evaluate_position(game: game.Game) -> Int { 17 + use eval, _position, #(piece, colour) <- dict.fold(game.board, 0) 18 + case colour == game.to_move { 19 + True -> eval + piece_score(piece) 20 + False -> eval - piece_score(piece) 21 + } 22 + } 23 + 24 + fn piece_score(piece: board.Piece) -> Int { 25 + case piece { 26 + board.King -> 0 27 + board.Pawn -> 100 28 + board.Knight -> 300 29 + board.Bishop -> 300 30 + board.Rook -> 500 31 + board.Queen -> 900 32 + } 33 + }
+139
src/starfish/internal/search.gleam
··· 1 + import gleam/bool 2 + import gleam/option.{type Option, None, Some} 3 + import starfish/internal/evaluate 4 + import starfish/internal/game.{type Game} 5 + import starfish/internal/move 6 + 7 + /// Not really infinity, but a high enough number that nothing but explicit 8 + /// references to it will reach it. 9 + const infinity = 1_000_000_000 10 + 11 + type Move = 12 + move.Move(move.Legal) 13 + 14 + pub fn best_move(game: Game, depth: Int) -> Result(Move, Nil) { 15 + use <- bool.guard(depth < 1, Error(Nil)) 16 + let result = search_top_level(game, depth, move.legal(game), None, -infinity) 17 + 18 + case result { 19 + Ok(SearchResult(eval: _, move:)) -> Ok(move) 20 + Error(Nil) -> Error(Nil) 21 + } 22 + } 23 + 24 + type SearchResult { 25 + SearchResult(eval: Int, move: Move) 26 + } 27 + 28 + /// Standard minimax search, with alpha-beta pruning. For each position searched, 29 + /// we recursively search all possible positions resulting from it, then negate 30 + /// the result. This is because the evaluation for one side is the negative value 31 + /// of the evaluation for the other side. 32 + /// Once we reach depth 0, we perform a static evaluation of the board. (See the 33 + /// `evaluate` module) 34 + fn search_top_level( 35 + game: Game, 36 + depth: Int, 37 + legal_moves: List(Move), 38 + best_move: Option(Move), 39 + best_eval: Int, 40 + ) -> Result(SearchResult, Nil) { 41 + case legal_moves { 42 + [] -> 43 + case best_move { 44 + None -> Error(Nil) 45 + Some(best_move) -> Ok(SearchResult(move: best_move, eval: best_eval)) 46 + } 47 + [move, ..moves] -> { 48 + let eval = 49 + -search(move.apply(game, move), depth - 1, -infinity, -best_eval, 0) 50 + 51 + let #(best_move, best_eval) = case eval > best_eval { 52 + True -> #(Some(move), eval) 53 + False -> #(best_move, best_eval) 54 + } 55 + search_top_level(game, depth, moves, best_move, best_eval) 56 + } 57 + } 58 + } 59 + 60 + fn search( 61 + game: Game, 62 + depth: Int, 63 + best_eval: Int, 64 + best_opponent_move: Int, 65 + depth_searched: Int, 66 + ) -> Int { 67 + // If we have reached fifty moves, the game is already a draw, so there's no 68 + // point searching further. 69 + use <- bool.guard(game.half_moves >= 50, 0) 70 + 71 + case move.legal(game) { 72 + // If the game is in a checkmate or stalemate position, the game is over, so 73 + // we stop searching. 74 + // Sooner checkmate is better. Or from the perspective of the side being 75 + // mated, later checkmate is better. 76 + [] if game.attack_information.in_check -> -infinity + depth_searched 77 + [] -> 0 78 + moves -> 79 + case depth { 80 + // Once we reach the limit of our depth, we statically evaluate the position. 81 + 0 -> evaluate.evaluate(game, moves) 82 + _ -> { 83 + search_loop( 84 + game, 85 + moves, 86 + depth, 87 + best_eval, 88 + best_opponent_move, 89 + depth_searched, 90 + ) 91 + } 92 + } 93 + } 94 + } 95 + 96 + fn search_loop( 97 + game: Game, 98 + moves: List(Move), 99 + depth: Int, 100 + // The best evaluation we've encountered so far. 101 + best_eval: Int, 102 + // The best evaluation our opponent can get. If we find a position which scores 103 + // higher than this, we can discard it. That position is too good and we can 104 + // assume that our opponent would never let us get there. 105 + best_opponent_move: Int, 106 + depth_searched: Int, 107 + ) -> Int { 108 + case moves { 109 + [] -> best_eval 110 + [move, ..moves] -> { 111 + // Evaluate the position for the opponent. The negative of the opponent's 112 + // eval is our eval. 113 + let eval = 114 + -search( 115 + move.apply(game, move), 116 + depth - 1, 117 + -best_opponent_move, 118 + -best_eval, 119 + depth_searched + 1, 120 + ) 121 + 122 + use <- bool.guard(eval >= best_opponent_move, best_opponent_move) 123 + 124 + let best_eval = case eval > best_eval { 125 + True -> eval 126 + False -> best_eval 127 + } 128 + 129 + search_loop( 130 + game, 131 + moves, 132 + depth, 133 + best_eval, 134 + best_opponent_move, 135 + depth_searched, 136 + ) 137 + } 138 + } 139 + }
+12
test/starfish_test.gleam
··· 166 166 io.println_error("\nPerft test of " <> name <> " took " <> time <> ".") 167 167 } 168 168 169 + pub fn search_test_() { 170 + use <- Timeout(1_000_000) 171 + use <- pocket_watch.callback("search", print_time) 172 + let assert Ok(move) = 173 + starfish.search( 174 + starfish.from_fen("8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1"), 175 + to_depth: 5, 176 + ) 177 + // b4f4 178 + assert move == move.Capture(from: 25, to: 29) 179 + } 180 + 169 181 pub fn perft_initial_position_test_() { 170 182 use <- Timeout(1_000_000) 171 183 use <- pocket_watch.callback("initial position", print_time)