A chess library for Gleam
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}