A chess library for Gleam

Make move generation faster by inlining functions and caching king positions

+264 -49
+6
src/starfish.gleam
··· 45 45 /// `rnbqkbnr/8/8/8/8/RNBQKBNR w - - 0 1`, only 6 ranks are specified, which 46 46 /// would cause this error. 47 47 PiecePositionsIncomplete 48 + /// The board is missing the white king, meaning the position is invalid. 49 + MissingWhiteKing 50 + /// The board is missing the black king, meaning the position is invalid. 51 + MissingBlackKing 48 52 /// The field specifying which player's turn is next is wrong or missing. For 49 53 /// example in the string `8/8/8/8/8/8/8/8 - - 0 1`, the active colour specifier 50 54 /// is missing. ··· 96 100 game.ExpectedSpaceAfterSegment -> ExpectedSpaceAfterSegment 97 101 game.PiecePositionsIncomplete -> PiecePositionsIncomplete 98 102 game.TrailingData(value) -> TrailingData(value) 103 + game.MissingBlackKing -> MissingBlackKing 104 + game.MissingWhiteKing -> MissingWhiteKing 99 105 } 100 106 } 101 107
+147 -15
src/starfish/internal/board.gleam
··· 1 1 import gleam/bool 2 2 import gleam/dict 3 3 import gleam/int 4 + import gleam/option 4 5 import gleam/result 5 6 6 7 pub const side_length = 8 ··· 110 111 Ok(#(position(file:, rank:), fen)) 111 112 } 112 113 113 - pub fn from_fen(fen: String) -> #(Board, String, Bool) { 114 + pub type FenParseResult { 115 + FenParseResult( 116 + board: Board, 117 + remaining: String, 118 + board_is_complete: Bool, 119 + white_king_position: option.Option(Int), 120 + black_king_position: option.Option(Int), 121 + ) 122 + } 123 + 124 + pub fn from_fen(fen: String) -> FenParseResult { 114 125 // FEN starts from black's size, which means that `rank` needs to start at the 115 126 // end of the board. 116 - from_fen_loop(fen, 0, side_length - 1, dict.new()) 127 + from_fen_loop(fen, 0, side_length - 1, dict.new(), option.None, option.None) 117 128 } 118 129 119 130 fn from_fen_loop( ··· 121 132 file: Int, 122 133 rank: Int, 123 134 board: Board, 124 - ) -> #(Board, String, Bool) { 135 + white_king_position: option.Option(Int), 136 + black_king_position: option.Option(Int), 137 + ) -> FenParseResult { 125 138 let position = position(file:, rank:) 126 139 127 140 case fen { 128 141 // When we hit a `/`, we start from file 0 again, and move down the rank, 129 142 // since we are starting from black and ending on white. 130 - "/" <> fen -> from_fen_loop(fen, 0, rank - 1, board) 131 - "0" <> fen -> from_fen_loop(fen, file, rank, board) 132 - "1" <> fen -> from_fen_loop(fen, file + 1, rank, board) 133 - "2" <> fen -> from_fen_loop(fen, file + 2, rank, board) 134 - "3" <> fen -> from_fen_loop(fen, file + 3, rank, board) 135 - "4" <> fen -> from_fen_loop(fen, file + 4, rank, board) 136 - "5" <> fen -> from_fen_loop(fen, file + 5, rank, board) 137 - "6" <> fen -> from_fen_loop(fen, file + 6, rank, board) 138 - "7" <> fen -> from_fen_loop(fen, file + 7, rank, board) 139 - "8" <> fen -> from_fen_loop(fen, file + 8, rank, board) 140 - "9" <> fen -> from_fen_loop(fen, file + 9, rank, board) 143 + "/" <> fen -> 144 + from_fen_loop( 145 + fen, 146 + 0, 147 + rank - 1, 148 + board, 149 + white_king_position, 150 + black_king_position, 151 + ) 152 + "0" <> fen -> 153 + from_fen_loop( 154 + fen, 155 + file, 156 + rank, 157 + board, 158 + white_king_position, 159 + black_king_position, 160 + ) 161 + "1" <> fen -> 162 + from_fen_loop( 163 + fen, 164 + file + 1, 165 + rank, 166 + board, 167 + white_king_position, 168 + black_king_position, 169 + ) 170 + "2" <> fen -> 171 + from_fen_loop( 172 + fen, 173 + file + 2, 174 + rank, 175 + board, 176 + white_king_position, 177 + black_king_position, 178 + ) 179 + "3" <> fen -> 180 + from_fen_loop( 181 + fen, 182 + file + 3, 183 + rank, 184 + board, 185 + white_king_position, 186 + black_king_position, 187 + ) 188 + "4" <> fen -> 189 + from_fen_loop( 190 + fen, 191 + file + 4, 192 + rank, 193 + board, 194 + white_king_position, 195 + black_king_position, 196 + ) 197 + "5" <> fen -> 198 + from_fen_loop( 199 + fen, 200 + file + 5, 201 + rank, 202 + board, 203 + white_king_position, 204 + black_king_position, 205 + ) 206 + "6" <> fen -> 207 + from_fen_loop( 208 + fen, 209 + file + 6, 210 + rank, 211 + board, 212 + white_king_position, 213 + black_king_position, 214 + ) 215 + "7" <> fen -> 216 + from_fen_loop( 217 + fen, 218 + file + 7, 219 + rank, 220 + board, 221 + white_king_position, 222 + black_king_position, 223 + ) 224 + "8" <> fen -> 225 + from_fen_loop( 226 + fen, 227 + file + 8, 228 + rank, 229 + board, 230 + white_king_position, 231 + black_king_position, 232 + ) 233 + "9" <> fen -> 234 + from_fen_loop( 235 + fen, 236 + file + 9, 237 + rank, 238 + board, 239 + white_king_position, 240 + black_king_position, 241 + ) 141 242 "K" <> fen -> 142 243 from_fen_loop( 143 244 fen, 144 245 file + 1, 145 246 rank, 146 247 dict.insert(board, position, #(King, White)), 248 + option.Some(position), 249 + black_king_position, 147 250 ) 148 251 "Q" <> fen -> 149 252 from_fen_loop( ··· 151 254 file + 1, 152 255 rank, 153 256 dict.insert(board, position, #(Queen, White)), 257 + white_king_position, 258 + black_king_position, 154 259 ) 155 260 "B" <> fen -> 156 261 from_fen_loop( ··· 158 263 file + 1, 159 264 rank, 160 265 dict.insert(board, position, #(Bishop, White)), 266 + white_king_position, 267 + black_king_position, 161 268 ) 162 269 "N" <> fen -> 163 270 from_fen_loop( ··· 165 272 file + 1, 166 273 rank, 167 274 dict.insert(board, position, #(Knight, White)), 275 + white_king_position, 276 + black_king_position, 168 277 ) 169 278 "R" <> fen -> 170 279 from_fen_loop( ··· 172 281 file + 1, 173 282 rank, 174 283 dict.insert(board, position, #(Rook, White)), 284 + white_king_position, 285 + black_king_position, 175 286 ) 176 287 "P" <> fen -> 177 288 from_fen_loop( ··· 179 290 file + 1, 180 291 rank, 181 292 dict.insert(board, position, #(Pawn, White)), 293 + white_king_position, 294 + black_king_position, 182 295 ) 183 296 "k" <> fen -> 184 297 from_fen_loop( ··· 186 299 file + 1, 187 300 rank, 188 301 dict.insert(board, position, #(King, Black)), 302 + white_king_position, 303 + option.Some(position), 189 304 ) 190 305 "q" <> fen -> 191 306 from_fen_loop( ··· 193 308 file + 1, 194 309 rank, 195 310 dict.insert(board, position, #(Queen, Black)), 311 + white_king_position, 312 + black_king_position, 196 313 ) 197 314 "b" <> fen -> 198 315 from_fen_loop( ··· 200 317 file + 1, 201 318 rank, 202 319 dict.insert(board, position, #(Bishop, Black)), 320 + white_king_position, 321 + black_king_position, 203 322 ) 204 323 "n" <> fen -> 205 324 from_fen_loop( ··· 207 326 file + 1, 208 327 rank, 209 328 dict.insert(board, position, #(Knight, Black)), 329 + white_king_position, 330 + black_king_position, 210 331 ) 211 332 "r" <> fen -> 212 333 from_fen_loop( ··· 214 335 file + 1, 215 336 rank, 216 337 dict.insert(board, position, #(Rook, Black)), 338 + white_king_position, 339 + black_king_position, 217 340 ) 218 341 "p" <> fen -> 219 342 from_fen_loop( ··· 221 344 file + 1, 222 345 rank, 223 346 dict.insert(board, position, #(Pawn, Black)), 347 + white_king_position, 348 + black_king_position, 224 349 ) 225 350 // Since we iterate the rank in reverse order, but we iterate the file in 226 351 // ascending order, the final position should equal to `side_length` 227 - _ -> #(board, fen, position == side_length) 352 + _ -> 353 + FenParseResult( 354 + board:, 355 + remaining: fen, 356 + board_is_complete: position == side_length, 357 + white_king_position:, 358 + black_king_position:, 359 + ) 228 360 } 229 361 } 230 362
+54 -6
src/starfish/internal/game.gleam
··· 29 29 zobrist_hash: Int, 30 30 previous_positions: List(Int), 31 31 attack_information: attack.AttackInformation, 32 + white_king_position: Int, 33 + black_king_position: Int, 32 34 ) 33 35 } 34 36 ··· 38 40 let to_move = White 39 41 let board = board.initial_position() 40 42 let zobrist_hash = hash.hash(board, to_move) 41 - let attack_information = attack.calculate(board, to_move) 42 43 44 + let white_king_position = 4 45 + let black_king_position = 60 46 + 47 + let attack_information = attack.calculate(board, white_king_position, to_move) 43 48 Game( 44 49 board:, 45 50 to_move:, ··· 50 55 zobrist_hash:, 51 56 previous_positions: [], 52 57 attack_information:, 58 + white_king_position:, 59 + black_king_position:, 53 60 ) 54 61 } 55 62 56 63 pub fn from_fen(fen: String) -> Game { 57 64 let fen = strip_spaces(fen) 58 65 59 - let #(board, fen, _) = board.from_fen(fen) 66 + let board.FenParseResult( 67 + board:, 68 + remaining: fen, 69 + board_is_complete: _, 70 + white_king_position:, 71 + black_king_position:, 72 + ) = board.from_fen(fen) 60 73 let fen = strip_spaces(fen) 61 74 62 75 let #(to_move, fen) = case fen { ··· 92 105 } 93 106 94 107 let zobrist_hash = hash.hash(board, to_move) 95 - let attack_information = attack.calculate(board, to_move) 108 + 109 + let white_king_position = option.unwrap(white_king_position, 0) 110 + let black_king_position = option.unwrap(black_king_position, 0) 111 + 112 + let king_position = case to_move { 113 + Black -> black_king_position 114 + White -> white_king_position 115 + } 116 + let attack_information = attack.calculate(board, king_position, to_move) 96 117 97 118 Game( 98 119 board:, ··· 104 125 zobrist_hash:, 105 126 previous_positions: [], 106 127 attack_information:, 128 + white_king_position:, 129 + black_king_position:, 107 130 ) 108 131 } 109 132 ··· 183 206 184 207 pub type FenParseError { 185 208 PiecePositionsIncomplete 209 + MissingWhiteKing 210 + MissingBlackKing 186 211 ExpectedActiveColour 187 212 ExpectedSpaceAfterSegment 188 213 TrailingData(String) ··· 195 220 pub fn try_from_fen(fen: String) -> Result(Game, FenParseError) { 196 221 let fen = strip_spaces(fen) 197 222 198 - let #(board, fen, completed) = board.from_fen(fen) 199 - use <- bool.guard(!completed, Error(PiecePositionsIncomplete)) 223 + let board.FenParseResult( 224 + board:, 225 + remaining: fen, 226 + board_is_complete:, 227 + white_king_position:, 228 + black_king_position:, 229 + ) = board.from_fen(fen) 230 + use <- bool.guard(!board_is_complete, Error(PiecePositionsIncomplete)) 231 + use white_king_position <- result.try(option.to_result( 232 + white_king_position, 233 + MissingWhiteKing, 234 + )) 235 + use black_king_position <- result.try(option.to_result( 236 + black_king_position, 237 + MissingBlackKing, 238 + )) 239 + 200 240 use fen <- result.try(expect_spaces(fen)) 201 241 202 242 use #(to_move, fen) <- result.try(case fen { ··· 233 273 use <- bool.guard(fen != "", Error(TrailingData(fen))) 234 274 235 275 let zobrist_hash = hash.hash(board, to_move) 236 - let attack_information = attack.calculate(board, to_move) 276 + 277 + let king_position = case to_move { 278 + Black -> black_king_position 279 + White -> white_king_position 280 + } 281 + 282 + let attack_information = attack.calculate(board, king_position, to_move) 237 283 238 284 Ok(Game( 239 285 board:, ··· 245 291 zobrist_hash:, 246 292 previous_positions: [], 247 293 attack_information:, 294 + white_king_position:, 295 + black_king_position:, 248 296 )) 249 297 } 250 298
+49 -17
src/starfish/internal/move.gleam
··· 109 109 // promotions, because they must move to the same rank. A double-move can 110 110 // never be a promotion because a pawn cannot double move from a position that 111 111 // ends on the promotion rank. 112 - let is_promotion = board.rank(forward_one) == promotion_rank 112 + let is_promotion = forward_one / 8 == promotion_rank 113 113 114 114 let moves = case board.get(game.board, forward_one) { 115 115 board.Empty -> { ··· 122 122 True -> [Move(from: position, to: forward_one), ..moves] 123 123 } 124 124 125 - let can_double_move = case game.to_move, board.rank(position) { 125 + let can_double_move = case game.to_move, position / 8 { 126 126 board.Black, 6 | board.White, 1 -> True 127 127 _, _ -> False 128 128 } ··· 178 178 } 179 179 180 180 fn en_passant_is_valid(game: Game, position: Int, new_position: Int) -> Bool { 181 - let captured_pawn_position = 182 - board.position(file: board.file(new_position), rank: board.rank(position)) 181 + let captured_pawn_position = new_position % 8 + position / 8 * 8 183 182 184 183 case game.attack_information.in_check { 185 184 False -> ··· 452 451 pub fn apply(game: Game, move: Move) -> game.Game { 453 452 case move { 454 453 Capture(from:, to:) -> do_apply(game, from, to, False, None, True) 455 - Castle(from:, to:) -> apply_castle(game, from, to, board.file(to) == 2) 454 + Castle(from:, to:) -> apply_castle(game, from, to, to % 8 == 2) 456 455 EnPassant(from:, to:) -> do_apply(game, from, to, True, None, True) 457 456 Move(from:, to:) -> do_apply(game, from, to, False, None, False) 458 457 Promotion(from:, to:, piece:) -> ··· 471 470 zobrist_hash:, 472 471 previous_positions:, 473 472 attack_information: _, 473 + white_king_position:, 474 + black_king_position:, 474 475 ) = game 475 476 476 477 let assert board.Occupied(piece:, colour:) = board.get(board, from) ··· 483 484 game.Castling(..castling, white_kingside: False, white_queenside: False) 484 485 } 485 486 486 - let rook_rank = board.rank(from) 487 + let rook_rank = from / 8 487 488 let #(rook_file_from, rook_file_to) = case long { 488 489 True -> #(0, 3) 489 490 False -> #(7, 5) ··· 492 493 let board = 493 494 board 494 495 |> dict.delete(from) 495 - |> dict.delete(board.position(file: rook_file_from, rank: rook_rank)) 496 + |> dict.delete(rook_rank * 8 + rook_file_from) 496 497 |> dict.insert(to, #(piece, colour)) 497 - |> dict.insert(board.position(file: rook_file_to, rank: rook_rank), #( 498 - board.Rook, 499 - colour, 500 - )) 498 + |> dict.insert(rook_rank * 8 + rook_file_to, #(board.Rook, colour)) 501 499 502 500 let en_passant_square = None 503 501 502 + let white_king_position = case to_move { 503 + board.White -> to 504 + board.Black -> white_king_position 505 + } 506 + 507 + let black_king_position = case to_move { 508 + board.Black -> to 509 + board.White -> black_king_position 510 + } 511 + 504 512 let full_moves = case to_move { 505 513 board.Black -> full_moves + 1 506 514 board.White -> full_moves ··· 517 525 // TODO: Update incrementally 518 526 let zobrist_hash = hash.hash(board, to_move) 519 527 528 + let king_position = case to_move { 529 + board.Black -> black_king_position 530 + board.White -> white_king_position 531 + } 520 532 // TODO: Maybe we can update this incrementally too? 521 - let attack_information = attack.calculate(board, to_move) 533 + let attack_information = attack.calculate(board, king_position, to_move) 522 534 523 535 Game( 524 536 board:, ··· 530 542 zobrist_hash:, 531 543 previous_positions:, 532 544 attack_information:, 545 + white_king_position:, 546 + black_king_position:, 533 547 ) 534 548 } 535 549 ··· 551 565 zobrist_hash:, 552 566 previous_positions:, 553 567 attack_information: _, 568 + white_king_position:, 569 + black_king_position:, 554 570 ) = game 555 571 556 572 let assert board.Occupied(piece:, colour:) = board.get(board, from) ··· 598 614 let #(half_moves, previous_positions) = case one_way_move { 599 615 True -> #(0, []) 600 616 False -> #(half_moves + 1, [zobrist_hash, ..previous_positions]) 617 + } 618 + 619 + let white_king_position = case from == white_king_position { 620 + True -> to 621 + False -> white_king_position 622 + } 623 + 624 + let black_king_position = case from == black_king_position { 625 + True -> to 626 + False -> black_king_position 601 627 } 602 628 603 629 // TODO: Update incrementally 604 630 let zobrist_hash = hash.hash(board, to_move) 605 631 632 + let king_position = case to_move { 633 + board.Black -> black_king_position 634 + board.White -> white_king_position 635 + } 606 636 // TODO: Maybe we can update this incrementally too? 607 - let attack_information = attack.calculate(board, to_move) 637 + let attack_information = attack.calculate(board, king_position, to_move) 608 638 609 639 Game( 610 640 board:, ··· 616 646 zobrist_hash:, 617 647 previous_positions:, 618 648 attack_information:, 649 + white_king_position:, 650 + black_king_position:, 619 651 ) 620 652 } 621 653 ··· 806 838 807 839 use <- bool.guard(move != "", Error(Nil)) 808 840 809 - let to = board.position(file: to_file, rank: to_rank) 841 + let to = to_rank * 8 + to_file 810 842 811 843 case get_pieces(game, piece_kind, legal_moves, from_file, from_rank, to) { 812 844 [from] if capture -> Ok(Capture(from:, to:)) ··· 896 928 _ -> Error(Nil) 897 929 }) 898 930 899 - let to = board.position(file: to_file, rank:) 931 + let to = rank * 8 + to_file 900 932 901 933 case 902 934 get_pieces(game, board.Pawn, legal_moves, from_file, None, to), ··· 926 958 && piece == find_piece 927 959 && case from_file { 928 960 None -> True 929 - Some(file) -> file == board.file(position) 961 + Some(file) -> file == position % 8 930 962 } 931 963 && case from_rank { 932 964 None -> True 933 - Some(rank) -> rank == board.rank(position) 965 + Some(rank) -> rank == position / 8 934 966 } 935 967 && list.any(legal_moves, fn(move) { move.to == to && move.from == position }) 936 968
+5 -8
src/starfish/internal/move/attack.gleam
··· 27 27 ) 28 28 } 29 29 30 - pub fn calculate(board: Board, to_move: board.Colour) -> AttackInformation { 31 - // TODO: keep track of this 32 - let assert Ok(#(king_position, _)) = 33 - board 34 - |> dict.to_list 35 - |> list.find(fn(pair) { pair.1 == #(board.King, to_move) }) 36 - as "Failed to find king on board" 37 - 30 + pub fn calculate( 31 + board: Board, 32 + king_position: Int, 33 + to_move: board.Colour, 34 + ) -> AttackInformation { 38 35 let attacking = case to_move { 39 36 board.Black -> board.White 40 37 board.White -> board.Black
+3 -3
src/starfish/internal/move/direction.gleam
··· 9 9 /// Returns a position moved in a given direction, checking for it being within 10 10 /// the bounds of the board. 11 11 pub fn in_direction(position: Int, direction: Direction) -> Int { 12 - let file = board.file(position) + direction.file_change 13 - let rank = board.rank(position) + direction.rank_change 12 + let file = position % 8 + direction.file_change 13 + let rank = position / 8 + direction.rank_change 14 14 15 15 case 16 16 file >= board.side_length ··· 19 19 || rank < 0 20 20 { 21 21 True -> -1 22 - False -> board.position(file:, rank:) 22 + False -> rank * 8 + file 23 23 } 24 24 } 25 25