A chess library for Gleam
at main 230 lines 7.8 kB view raw
1import gleam/bool 2import gleam/list 3import gleam/result 4import starfish/internal/board 5import starfish/internal/game 6import starfish/internal/move 7import starfish/internal/search 8 9pub opaque type Game { 10 Game(game: game.Game) 11} 12 13/// A single legal move on the chess board. 14pub opaque type Move { 15 Move(move: move.Move) 16} 17 18@internal 19pub fn get_move(move: Move) -> move.Move { 20 move.move 21} 22 23/// The [FEN string](https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation) 24/// representing the initial position of a chess game. 25pub const starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" 26 27/// Parses a game from a FEN string. This function does a best-effort parsing of 28/// the input, meaning if a FEN string is partially incomplete (e.g. missing the 29/// half-move and full-move counters at the end), it will fill it in with the 30/// default values of the starting position. 31/// 32/// For strict parsing, see [`try_from_fen`](#try_from_fen). 33/// 34/// ## Examples 35/// 36/// The following expressions are all equivalent: 37/// 38/// ```gleam 39/// starfish.new() 40/// starfish.from_fen(starfish.starting_fen) 41/// // Here, we provide the board position and the rest of the information is 42/// // filled in. 43/// starfish.from_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR") 44/// ``` 45pub fn from_fen(fen: String) -> Game { 46 Game(game.from_fen(fen)) 47} 48 49pub type FenParseError { 50 /// The field specifying the positions of piece on the board is incomplete and 51 /// doesn't cover every square on the chessboard. For example, in the string 52 /// `rnbqkbnr/8/8/8/8/RNBQKBNR w - - 0 1`, only 6 ranks are specified, which 53 /// would cause this error. 54 PiecePositionsIncomplete 55 /// The board is missing the white king, meaning the position is invalid. 56 MissingWhiteKing 57 /// The board is missing the black king, meaning the position is invalid. 58 MissingBlackKing 59 /// The field specifying which player's turn is next is wrong or missing. For 60 /// example in the string `8/8/8/8/8/8/8/8 - - 0 1`, the active colour specifier 61 /// is missing. 62 ExpectedActiveColour 63 /// If a segment is not followed by a space. For example in the string: 64 /// `8/8/8/8/8/8/8/8w--01` 65 ExpectedSpaceAfterSegment 66 /// After the FEN string is successfully parsed, there is extra data at the 67 /// end of the string (whitespace doesn't count). 68 TrailingData(String) 69 /// The field specifying the en passant square is missing. 70 ExpectedEnPassantPosition 71 /// The field specifying the half-move count is missing. 72 ExpectedHalfMoveCount 73 /// The field specifying the full-move count is missing. 74 ExpectedFullMoveCount 75 /// When specifying castling rights, one of the characters is duplicated. For 76 /// example, in the string `8/8/8/8/8/8/8/8 w KKKQQQkkqq - 0 1`. 77 DuplicateCastlingIndicator 78} 79 80/// Tries to parse a game from a FEN string, returning an error if it doesn't 81/// follow standard FEN notation. For more lenient parsing, see [`from_fen`]( 82/// #from_fen). 83/// 84/// ## Examples 85/// 86/// ```gleam 87/// let assert Ok(start_pos) = starfish.try_from_fen(starfish.starting_fen) 88/// assert start_pos == starfish.new() 89/// 90/// let assert Error(starfish.ExpectedSpaceAfterSegment) = 91/// starfish.try_from_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR") 92/// ``` 93pub fn try_from_fen(fen: String) -> Result(Game, FenParseError) { 94 game.try_from_fen(fen) 95 |> result.map_error(convert_fen_parse_error) 96 |> result.map(Game) 97} 98 99// Since circular imports are not allowed, but we want the user to be able to 100// pattern match on the type from the internal module, we convert it here to an 101// identical type which is part of the public API. 102fn convert_fen_parse_error(error: game.FenParseError) -> FenParseError { 103 case error { 104 game.DuplicateCastlingIndicator -> DuplicateCastlingIndicator 105 game.ExpectedActiveColour -> ExpectedActiveColour 106 game.ExpectedEnPassantPosition -> ExpectedEnPassantPosition 107 game.ExpectedFullMoveCount -> ExpectedFullMoveCount 108 game.ExpectedHalfMoveCount -> ExpectedHalfMoveCount 109 game.ExpectedSpaceAfterSegment -> ExpectedSpaceAfterSegment 110 game.PiecePositionsIncomplete -> PiecePositionsIncomplete 111 game.TrailingData(value) -> TrailingData(value) 112 game.MissingBlackKing -> MissingBlackKing 113 game.MissingWhiteKing -> MissingWhiteKing 114 } 115} 116 117/// Returns a game representing the initial position. 118pub fn new() -> Game { 119 Game(game.initial_position()) 120} 121 122/// Convert a game into its FEN string representation. 123/// 124/// ## Examples 125/// 126/// ```gleam 127/// assert starfish.to_fen(starfish.new()) == starfish.starting_fen 128/// ``` 129pub fn to_fen(game: Game) -> String { 130 game.to_fen(game.game) 131} 132 133pub fn legal_moves(game: Game) -> List(Move) { 134 list.map(move.legal(game.game), Move) 135} 136 137/// Used to determine how long to search positions 138pub type SearchCutoff { 139 /// Search to a specific depth 140 Depth(depth: Int) 141 /// Search for a given number of milliseconds. 142 /// 143 /// NOTE: The process will usually take slightly longer than the specified time. 144 /// It would be expensive to check the time every millisecond, so it is checked 145 /// periodically. This is usually less than 10ms, but it can be higher than that. 146 Time(milliseconds: Int) 147} 148 149/// Finds the best move for a given position, or returns an error if no moves are 150/// legal (If it's checkmate or stalemate) 151pub fn search(game: Game, until cutoff: SearchCutoff) -> Result(Move, Nil) { 152 let until = case cutoff { 153 Depth(depth:) -> fn(current_depth) { current_depth > depth } 154 Time(milliseconds:) -> { 155 let end_time = monotonic_time() + milliseconds 156 fn(_) { monotonic_time() >= end_time } 157 } 158 } 159 160 result.map(search.best_move(game.game, until), Move) 161} 162 163@external(erlang, "starfish_ffi", "monotonic_time") 164@external(javascript, "./starfish_ffi.mjs", "monotonic_time") 165fn monotonic_time() -> Int 166 167pub fn apply_move(game: Game, move: Move) -> Game { 168 Game(move.apply(game.game, move.move)) 169} 170 171pub fn to_standard_algebraic_notation(move: Move, game: Game) -> String { 172 move.to_standard_algebraic_notation(move.move, game.game) 173} 174 175/// Convert a move to [long algebraic notation]( 176/// https://en.wikipedia.org/wiki/Algebraic_notation_(chess)#Long_algebraic_notation), 177/// specifically the UCI format, containing the start and end positions. For 178/// example, `e2e4` or `c7d8q`. 179pub fn to_long_algebraic_notation(move: Move) -> String { 180 move.to_long_algebraic_notation(move.move) 181} 182 183/// Parses a move from either long algebraic notation, in the same format as 184/// [`to_long_algebraic_notation`](#to_long_algebraic_notation), or from [Standard 185/// Algebraic Notation](https://en.wikipedia.org/wiki/Algebraic_notation_(chess)). 186/// Returns an error if the syntax is invalid or the move is not legal on the 187/// board. 188pub fn parse_move(move: String, game: Game) -> Result(Move, Nil) { 189 let legal_moves = move.legal(game.game) 190 case move.from_long_algebraic_notation(move, legal_moves) { 191 Ok(move) -> Ok(Move(move)) 192 Error(_) -> 193 move.from_standard_algebraic_notation(move, game.game, legal_moves) 194 |> result.map(Move) 195 } 196} 197 198pub type GameState { 199 Continue 200 Draw(DrawReason) 201 WhiteWin 202 BlackWin 203} 204 205pub type DrawReason { 206 ThreefoldRepetition 207 InsufficientMaterial 208 Stalemate 209 FiftyMoves 210} 211 212/// Returns the current game state: A win, draw or neither. 213pub fn state(game: Game) -> GameState { 214 let game = game.game 215 use <- bool.guard(game.half_moves >= 50, Draw(FiftyMoves)) 216 use <- bool.guard( 217 game.is_insufficient_material(game), 218 Draw(InsufficientMaterial), 219 ) 220 use <- bool.guard( 221 game.is_threefold_repetition(game), 222 Draw(ThreefoldRepetition), 223 ) 224 use <- bool.guard(move.any_legal(game), Continue) 225 use <- bool.guard(!game.attack_information.in_check, Draw(Stalemate)) 226 case game.to_move { 227 board.Black -> WhiteWin 228 board.White -> BlackWin 229 } 230}