···11+import gleam/dict
22+import gleam/list
33+import starfish/internal/board
44+import starfish/internal/game
55+import starfish/internal/move
66+77+/// Statically evaluates a position. Does not take into account checkmate or
88+/// stalemate, those must be accounted for beforehand.
99+pub fn evaluate(
1010+ game: game.Game,
1111+ legal_moves: List(move.Move(move.Legal)),
1212+) -> Int {
1313+ evaluate_position(game) + list.length(legal_moves)
1414+}
1515+1616+fn evaluate_position(game: game.Game) -> Int {
1717+ use eval, _position, #(piece, colour) <- dict.fold(game.board, 0)
1818+ case colour == game.to_move {
1919+ True -> eval + piece_score(piece)
2020+ False -> eval - piece_score(piece)
2121+ }
2222+}
2323+2424+fn piece_score(piece: board.Piece) -> Int {
2525+ case piece {
2626+ board.King -> 0
2727+ board.Pawn -> 100
2828+ board.Knight -> 300
2929+ board.Bishop -> 300
3030+ board.Rook -> 500
3131+ board.Queen -> 900
3232+ }
3333+}
+139
src/starfish/internal/search.gleam
···11+import gleam/bool
22+import gleam/option.{type Option, None, Some}
33+import starfish/internal/evaluate
44+import starfish/internal/game.{type Game}
55+import starfish/internal/move
66+77+/// Not really infinity, but a high enough number that nothing but explicit
88+/// references to it will reach it.
99+const infinity = 1_000_000_000
1010+1111+type Move =
1212+ move.Move(move.Legal)
1313+1414+pub fn best_move(game: Game, depth: Int) -> Result(Move, Nil) {
1515+ use <- bool.guard(depth < 1, Error(Nil))
1616+ let result = search_top_level(game, depth, move.legal(game), None, -infinity)
1717+1818+ case result {
1919+ Ok(SearchResult(eval: _, move:)) -> Ok(move)
2020+ Error(Nil) -> Error(Nil)
2121+ }
2222+}
2323+2424+type SearchResult {
2525+ SearchResult(eval: Int, move: Move)
2626+}
2727+2828+/// Standard minimax search, with alpha-beta pruning. For each position searched,
2929+/// we recursively search all possible positions resulting from it, then negate
3030+/// the result. This is because the evaluation for one side is the negative value
3131+/// of the evaluation for the other side.
3232+/// Once we reach depth 0, we perform a static evaluation of the board. (See the
3333+/// `evaluate` module)
3434+fn search_top_level(
3535+ game: Game,
3636+ depth: Int,
3737+ legal_moves: List(Move),
3838+ best_move: Option(Move),
3939+ best_eval: Int,
4040+) -> Result(SearchResult, Nil) {
4141+ case legal_moves {
4242+ [] ->
4343+ case best_move {
4444+ None -> Error(Nil)
4545+ Some(best_move) -> Ok(SearchResult(move: best_move, eval: best_eval))
4646+ }
4747+ [move, ..moves] -> {
4848+ let eval =
4949+ -search(move.apply(game, move), depth - 1, -infinity, -best_eval, 0)
5050+5151+ let #(best_move, best_eval) = case eval > best_eval {
5252+ True -> #(Some(move), eval)
5353+ False -> #(best_move, best_eval)
5454+ }
5555+ search_top_level(game, depth, moves, best_move, best_eval)
5656+ }
5757+ }
5858+}
5959+6060+fn search(
6161+ game: Game,
6262+ depth: Int,
6363+ best_eval: Int,
6464+ best_opponent_move: Int,
6565+ depth_searched: Int,
6666+) -> Int {
6767+ // If we have reached fifty moves, the game is already a draw, so there's no
6868+ // point searching further.
6969+ use <- bool.guard(game.half_moves >= 50, 0)
7070+7171+ case move.legal(game) {
7272+ // If the game is in a checkmate or stalemate position, the game is over, so
7373+ // we stop searching.
7474+ // Sooner checkmate is better. Or from the perspective of the side being
7575+ // mated, later checkmate is better.
7676+ [] if game.attack_information.in_check -> -infinity + depth_searched
7777+ [] -> 0
7878+ moves ->
7979+ case depth {
8080+ // Once we reach the limit of our depth, we statically evaluate the position.
8181+ 0 -> evaluate.evaluate(game, moves)
8282+ _ -> {
8383+ search_loop(
8484+ game,
8585+ moves,
8686+ depth,
8787+ best_eval,
8888+ best_opponent_move,
8989+ depth_searched,
9090+ )
9191+ }
9292+ }
9393+ }
9494+}
9595+9696+fn search_loop(
9797+ game: Game,
9898+ moves: List(Move),
9999+ depth: Int,
100100+ // The best evaluation we've encountered so far.
101101+ best_eval: Int,
102102+ // The best evaluation our opponent can get. If we find a position which scores
103103+ // higher than this, we can discard it. That position is too good and we can
104104+ // assume that our opponent would never let us get there.
105105+ best_opponent_move: Int,
106106+ depth_searched: Int,
107107+) -> Int {
108108+ case moves {
109109+ [] -> best_eval
110110+ [move, ..moves] -> {
111111+ // Evaluate the position for the opponent. The negative of the opponent's
112112+ // eval is our eval.
113113+ let eval =
114114+ -search(
115115+ move.apply(game, move),
116116+ depth - 1,
117117+ -best_opponent_move,
118118+ -best_eval,
119119+ depth_searched + 1,
120120+ )
121121+122122+ use <- bool.guard(eval >= best_opponent_move, best_opponent_move)
123123+124124+ let best_eval = case eval > best_eval {
125125+ True -> eval
126126+ False -> best_eval
127127+ }
128128+129129+ search_loop(
130130+ game,
131131+ moves,
132132+ depth,
133133+ best_eval,
134134+ best_opponent_move,
135135+ depth_searched,
136136+ )
137137+ }
138138+ }
139139+}
+12
test/starfish_test.gleam
···166166 io.println_error("\nPerft test of " <> name <> " took " <> time <> ".")
167167}
168168169169+pub fn search_test_() {
170170+ use <- Timeout(1_000_000)
171171+ use <- pocket_watch.callback("search", print_time)
172172+ let assert Ok(move) =
173173+ starfish.search(
174174+ starfish.from_fen("8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1"),
175175+ to_depth: 5,
176176+ )
177177+ // b4f4
178178+ assert move == move.Capture(from: 25, to: 29)
179179+}
180180+169181pub fn perft_initial_position_test_() {
170182 use <- Timeout(1_000_000)
171183 use <- pocket_watch.callback("initial position", print_time)