chess_engine_rb 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +83 -0
  4. data/lib/chess_engine/data_definitions/README.md +10 -0
  5. data/lib/chess_engine/data_definitions/board.rb +192 -0
  6. data/lib/chess_engine/data_definitions/components/castling_rights.rb +48 -0
  7. data/lib/chess_engine/data_definitions/components/persistent_array.rb +114 -0
  8. data/lib/chess_engine/data_definitions/events.rb +174 -0
  9. data/lib/chess_engine/data_definitions/piece.rb +159 -0
  10. data/lib/chess_engine/data_definitions/position.rb +137 -0
  11. data/lib/chess_engine/data_definitions/primitives/castling_data.rb +61 -0
  12. data/lib/chess_engine/data_definitions/primitives/colors.rb +26 -0
  13. data/lib/chess_engine/data_definitions/primitives/core_notation.rb +111 -0
  14. data/lib/chess_engine/data_definitions/primitives/movement.rb +52 -0
  15. data/lib/chess_engine/data_definitions/square.rb +98 -0
  16. data/lib/chess_engine/engine.rb +299 -0
  17. data/lib/chess_engine/errors.rb +35 -0
  18. data/lib/chess_engine/event_handlers/base_event_handler.rb +112 -0
  19. data/lib/chess_engine/event_handlers/castling_event_handler.rb +48 -0
  20. data/lib/chess_engine/event_handlers/en_passant_event_handler.rb +59 -0
  21. data/lib/chess_engine/event_handlers/init.rb +41 -0
  22. data/lib/chess_engine/event_handlers/move_event_handler.rb +144 -0
  23. data/lib/chess_engine/formatters/eran_formatters.rb +71 -0
  24. data/lib/chess_engine/formatters/init.rb +19 -0
  25. data/lib/chess_engine/formatters/validation.rb +54 -0
  26. data/lib/chess_engine/game/history.rb +25 -0
  27. data/lib/chess_engine/game/init.rb +15 -0
  28. data/lib/chess_engine/game/legal_moves_helper.rb +126 -0
  29. data/lib/chess_engine/game/query.rb +168 -0
  30. data/lib/chess_engine/game/state.rb +198 -0
  31. data/lib/chess_engine/parsers/eran_parser.rb +85 -0
  32. data/lib/chess_engine/parsers/identity_parser.rb +18 -0
  33. data/lib/chess_engine/parsers/init.rb +21 -0
  34. data/lib/chess_engine_rb.rb +46 -0
  35. metadata +112 -0
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_event_handler'
4
+ require_relative 'en_passant_event_handler'
5
+ require_relative '../data_definitions/piece'
6
+ require_relative '../data_definitions/square'
7
+ require_relative '../data_definitions/events'
8
+ require_relative '../data_definitions/primitives/colors'
9
+
10
+ module ChessEngine
11
+ module EventHandlers
12
+ # Event handler for `MovePieceEvent`
13
+ class MoveEventHandler < BaseEventHandler
14
+ private
15
+
16
+ def resolve
17
+ return failure("#{event} is not a MovePieceEvent") unless event.is_a?(MovePieceEvent)
18
+
19
+ resolving_methods = %i[resolve_to resolve_piece handle_en_passant
20
+ resolve_from resolve_captured resolve_promote_to]
21
+ en_passant_stop_cond = ->(result) { result.event.is_a?(MovePieceEvent) }
22
+ run_resolution_pipeline(*resolving_methods, handle_en_passant: en_passant_stop_cond)
23
+ end
24
+
25
+ # Has to have a full, valid :to
26
+ def resolve_to(event)
27
+ to = event.to
28
+ return failure(":to is not a valid Square: #{to}") unless to.is_a?(Square) && to.valid?
29
+
30
+ piece_at_to = board.get(to)
31
+ if piece_at_to&.color == current_color || piece_at_to&.type == :king || (event.captured.nil? && !piece_at_to.nil?)
32
+ return failure("Cannot move to #{to}")
33
+ end
34
+
35
+ success(event)
36
+ end
37
+
38
+ def resolve_piece(event)
39
+ return failure(":piece is not a Piece: #{event.piece}") unless event.piece.is_a?(Piece) || event.piece.nil?
40
+ return failure("Wrong color: #{event.piece.color}") unless [nil, current_color].include?(event.piece&.color)
41
+
42
+ piece_color = current_color
43
+ piece_type = event.piece&.type || :pawn # Default to pawn if no piece specified
44
+
45
+ # Validate piece type
46
+ unless Piece::TYPES.include?(piece_type)
47
+ return failure("Invalid piece type: #{piece_type}. Must be one of: #{Piece::TYPES.join(', ')}")
48
+ end
49
+
50
+ piece = Piece[piece_color, piece_type]
51
+ success(event.with(piece: piece))
52
+ end
53
+
54
+ def resolve_from(event)
55
+ return failure(":from is not a `Square`: #{event.from}") unless event.from.is_a?(Square) || event.from.nil?
56
+
57
+ # Determine the appropriate method to get piece moves,
58
+ # either `Piece#moves` or `Piece#threatened_squares`
59
+ piece_moves_method = board.get(event.to).nil? ? :moves : :threatened_squares
60
+ filtered_pieces = board.pieces_with_squares(color: event.piece.color, type: event.piece.type).select do |_p, s|
61
+ event.from.nil? || event.from.matches?(s)
62
+ end
63
+
64
+ # Filter pieces that can move to the destination
65
+ filtered_pieces = filtered_pieces.select do |p, s|
66
+ piece_moves = p.send(piece_moves_method, board, s)
67
+ piece_moves.any? { event.to == it }
68
+ end
69
+
70
+ return failure('Invalid piece at :from') if filtered_pieces.empty?
71
+ if filtered_pieces.size > 1
72
+ return failure(":from square disambiguation faild: too many pieces: #{filtered_pieces.inspect}")
73
+ end
74
+
75
+ _p, from = filtered_pieces.first
76
+ unless event.from.nil? || event.from.matches?(from)
77
+ return failure("Invalid :from square given: #{event.from}. Should be: #{from}")
78
+ end
79
+
80
+ success(event.with(from: from))
81
+ end
82
+
83
+ # Delegate to `EnPassantEventHandler` as needed
84
+ def handle_en_passant(event)
85
+ return success(event) unless should_en_passant?(event)
86
+
87
+ EnPassantEventHandler.call(@query, EnPassantEvent[event.piece.color, event.from, event.to])
88
+ end
89
+
90
+ def resolve_captured(event)
91
+ captured = event.captured
92
+ return success(event) if captured.nil?
93
+
94
+ return failure(":captured is of type #{captured.class}, not CaptureData") unless captured.is_a?(Events::CaptureData)
95
+ unless captured.square.nil? || captured.square.is_a?(Square)
96
+ return failure(":captured.square is not a Square: #{captured.square}")
97
+ end
98
+ unless captured.piece.nil? || captured.piece.is_a?(Piece)
99
+ return failure(":captured.piece is not a Piece: #{captured.piece}")
100
+ end
101
+ return failure('Invalid captured square') unless captured.square.nil? || captured.square.matches?(event.to)
102
+
103
+ captured_square = event.to
104
+ captured_piece = board.get(event.to)
105
+
106
+ unless [nil, other_color].include?(captured.piece&.color) &&
107
+ [nil, captured_piece&.type].include?(captured.piece&.type)
108
+ return failure("Invalid captured piece: #{captured.piece}, should be #{captured_piece}")
109
+ end
110
+ return failure("No piece to capture at #{captured_square}") if captured_piece.nil?
111
+
112
+ success(event.with(captured: Events::CaptureData[captured_square, captured_piece]))
113
+ end
114
+
115
+ def resolve_promote_to(event)
116
+ promote_to = event.promote_to
117
+ unless should_promote?(event)
118
+ return success(event) if promote_to.nil?
119
+
120
+ return failure("Given promotion, but cannot promote #{event.piece.type} at #{event.to}")
121
+ end
122
+
123
+ return failure("Pawn move to #{event.to} requires promotion") if promote_to.nil?
124
+
125
+ unless Piece::PROMOTION_TYPES.include?(promote_to)
126
+ return failure("Invalid promotion piece type: #{promote_to}. Needs to be one of: #{Piece::PROMOTION_TYPES.join(', ')}")
127
+ end
128
+
129
+ success(event)
130
+ end
131
+
132
+ def should_en_passant?(event)
133
+ position.en_passant_target && (event.piece.type == :pawn) && !event.captured.nil? &&
134
+ event.to == position.en_passant_target && event.promote_to.nil? &&
135
+ board.get(event.to).nil?
136
+ end
137
+
138
+ def should_promote?(event)
139
+ event.piece.type == :pawn &&
140
+ ((current_color == :white && event.to.rank == 8) || (current_color == :black && event.to.rank == 1))
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'immutable'
4
+ require_relative 'validation'
5
+ require_relative '../data_definitions/primitives/core_notation'
6
+
7
+ module ChessEngine
8
+ module Formatters
9
+ # Provides formatters for ERAN, a custom chess notation made for this engine.
10
+ #
11
+ # ERAN supports both long and short forms for many constructs; this module exposes formatters for each style.
12
+ # See the docs for ERAN for more details.
13
+ module ERANFormatters
14
+ extend self
15
+
16
+ PROMOTION_PREFIX = { long: '->', short: '>' }.freeze
17
+ EN_PASSANT = { long: 'en-passant', short: 'ep' }.freeze
18
+ CASTLING = Immutable.from(
19
+ {
20
+ long: {
21
+ kingside: 'castling-kingside',
22
+ queenside: 'castling-queenside'
23
+ },
24
+ short: {
25
+ kingside: 'ck',
26
+ queenside: 'cq'
27
+ }
28
+ }
29
+ )
30
+
31
+ # Since ERAN is case-insensitive, capitalizing piece type is only for aesthetic.
32
+ PIECE_TYPES = Immutable.from(
33
+ { long: Piece::TYPES.to_h { [it, it.to_s.capitalize] },
34
+ short: CoreNotation::PIECE_MAP }
35
+ )
36
+
37
+ def format(event, verbosity)
38
+ case event
39
+ in MovePieceEvent
40
+ format_move_piece_event(event, PIECE_TYPES[verbosity], PROMOTION_PREFIX[verbosity])
41
+ in EnPassantEvent
42
+ EN_PASSANT[verbosity]
43
+ in CastlingEvent
44
+ CASTLING[verbosity][event.side]
45
+ else
46
+ nil
47
+ end
48
+ end
49
+
50
+ def format_move_piece_event(event, piece_types, promotion_prefix) # rubocop:disable Metrics/AbcSize
51
+ return unless Validation.well_formed_move_piece?(event)
52
+
53
+ piece_type = piece_types[event.piece.type]
54
+ move_type = event.captured.nil? ? '-' : 'x'
55
+ movement = CoreNotation.square_to_str(event.from) + move_type + CoreNotation.square_to_str(event.to)
56
+ promotion = event.promote_to ? promotion_prefix + piece_types[event.promote_to] : nil
57
+
58
+ [piece_type, movement, promotion].compact.join(' ')
59
+ end
60
+
61
+ private :format, :format_move_piece_event
62
+
63
+ # The actual formatters
64
+ LONG = ->(event, _query = nil) { format(event, :long) }
65
+ SHORT = ->(event, _query = nil) { format(event, :short) }
66
+ end
67
+
68
+ ERANLongFormatter = ERANFormatters::LONG
69
+ ERANShortFormatter = ERANFormatters::SHORT
70
+ end
71
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'eran_formatters'
4
+
5
+ module ChessEngine
6
+ # Namespace for all chess notation formatters.
7
+ #
8
+ # Formatters convert fully-populated `Events::BaseEvent` objects into a single-move notation.
9
+ # The output must be a valid notation for a single move that accurately reflects the given event.
10
+ #
11
+ # It expects the event to be structurally valid:
12
+ # all fields and nested objects must be well-formed.
13
+ # Certain formatters and notations might also require the move to be valid under the game context.
14
+ #
15
+ # Each formatter should implement `.call(event, game_query)`, returning the corresponding notation,
16
+ # or `nil` if the event cannot be parsed.
17
+ module Formatters
18
+ end
19
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../data_definitions/piece'
4
+ require_relative '../data_definitions/square'
5
+ require_relative '../data_definitions/primitives/colors'
6
+ require_relative '../data_definitions/primitives/castling_data'
7
+
8
+ module ChessEngine
9
+ module Formatters
10
+ # Utilities for checking the structural validity of the given event
11
+ module Validation
12
+ module_function
13
+
14
+ # --- Shallow validity: basic fields required for most notation ---
15
+ # Only checks presence and immediate validity of fields, not nested captured pieces, color, etc.
16
+ def well_formed_move_piece?(event)
17
+ Piece::TYPES.include?(event.piece&.type) &&
18
+ square_and_valid?(event.from) &&
19
+ square_and_valid?(event.to) &&
20
+ (event.promote_to.nil? || Piece::PROMOTION_TYPES.include?(event.promote_to))
21
+ end
22
+
23
+ def well_formed_en_passant?(_event)
24
+ true # Most notations don't need any special validation for en passant
25
+ end
26
+
27
+ def well_formed_castling?(event)
28
+ CastlingData::SIDES.include?(event.side)
29
+ end
30
+
31
+ # --- Full validity: all nested fields and optional data are checked ---
32
+ # Useful when you want to ensure the event is completely well-formed for any purpose.
33
+ def fully_well_formed_move_piece?(event)
34
+ well_formed_move_piece?(event) && Colors.valid?(event.piece.color) &&
35
+ (event.captured.nil? || (square_and_valid?(event.captured.square) && piece_and_valid?(event.captured.piece)))
36
+ end
37
+
38
+ def fully_well_formed_en_passant?(event)
39
+ Colors.valid?(event.color) &&
40
+ (event.from.nil? || square_and_valid?(event.from)) &&
41
+ (event.to.nil? || square_and_valid?(event.to))
42
+ end
43
+
44
+ def fully_well_formed_castling?(event)
45
+ well_formed_castling?(event) && Colors.valid?(event.color)
46
+ end
47
+
48
+ # --- Components helpers ---
49
+ # Validate basic pieces or squares
50
+ def piece_and_valid?(piece) = piece.is_a?(Piece) && piece.valid?
51
+ def square_and_valid?(square) = square.is_a?(Square) && square.valid?
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'immutable'
4
+ require_relative '../data_definitions/position'
5
+
6
+ module ChessEngine
7
+ module Game
8
+ # Encapsulates all of the game's history, from a certain point up to a current point.
9
+ #
10
+ # Includes:
11
+ # - `start_position`: a `Position` object representing the position from which the history started.
12
+ # - `moves` - an `Enumerable` of events that happened up until the current point.
13
+ # - `position_signatures` - a hash of position signatures,
14
+ # that counts how much times each one occurred up to this point.
15
+ History = Data.define(:start_position, :moves, :position_signatures) do
16
+ def initialize(moves:, position_signatures: Immutable::Hash[], start_position: Position.start)
17
+ moves = Immutable.from moves
18
+ position_signatures = Immutable.from position_signatures
19
+ super
20
+ end
21
+
22
+ def self.start = new(Position.start, Immutable::List[], Immutable::Hash[])
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'state'
4
+ require_relative 'query'
5
+ require_relative 'history'
6
+
7
+ module ChessEngine
8
+ # Contains the core abstractions for representing and manipulating chess game state.
9
+ # The main entry point is `State`, which models the entire game at a given moment.
10
+ # Other components (`Query`, `History`) are dependencies of `State` but can be used standalone if needed.
11
+ #
12
+ # For details and usage, see the documentation in `State`.
13
+ module Game
14
+ end
15
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+ require_relative '../data_definitions/piece'
5
+ require_relative '../data_definitions/events'
6
+ require_relative '../data_definitions/primitives/colors'
7
+ require_relative '../data_definitions/primitives/castling_data'
8
+
9
+ module ChessEngine
10
+ module Game
11
+ class Query
12
+ # Implements the method `#legal_moves` for `Query`,
13
+ # which generates all legal moves for the given color.
14
+ module LegalMovesHelper
15
+ def legal_moves(color = @position.current_color)
16
+ return INVALID_ARGUMENT unless Colors.valid?(color)
17
+ return enum_for(__method__, color) unless block_given?
18
+
19
+ each_pseudo_legal_event(color).each do |event|
20
+ yield event unless state.apply_event(event).query.in_check?(color)
21
+ rescue InvalidEventError
22
+ next # Malformed events are considered illegal moves
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ # A pseudo-legal move is a move that is valid according to the rules of chess,
29
+ # except that it does not account for whether the move would leave the king in check.
30
+ def each_pseudo_legal_event(color, &)
31
+ return enum_for(__method__, color) unless block_given?
32
+
33
+ board.pieces_with_squares(color: color).each do |piece, square|
34
+ each_move_only_event(piece, square, &)
35
+ each_capture_event(piece, square, &)
36
+ end
37
+
38
+ each_enpassant_event(color, &)
39
+ each_castling_event(color, &)
40
+ end
41
+
42
+ def each_move_only_event(piece, square, &)
43
+ to_squares = piece.moves(board, square).select { board.get(it).nil? }
44
+
45
+ each_move_event(piece, square, to_squares, &)
46
+ end
47
+
48
+ def each_capture_event(piece, square, &)
49
+ capturable_squares = piece.threatened_squares(board, square).select do |target_square|
50
+ target_piece = board.get(target_square)
51
+ !target_piece.nil? && piece.color != target_piece.color
52
+ end
53
+
54
+ each_move_event(piece, square, capturable_squares) do |event|
55
+ yield event.capture(event.to, board.get(event.to))
56
+ end
57
+ end
58
+
59
+ def each_move_event(piece, square, to_squares, &)
60
+ to_squares.each do |target_square|
61
+ event = MovePieceEvent[piece, square, target_square]
62
+
63
+ if move_should_promote?(piece, target_square)
64
+ each_promotion_event(event, &)
65
+ else
66
+ yield event
67
+ end
68
+ end
69
+ end
70
+
71
+ def each_promotion_event(event, &)
72
+ Piece::PROMOTION_TYPES.each do |piece_type|
73
+ yield event.promote(piece_type)
74
+ end
75
+ end
76
+
77
+ def each_enpassant_event(color, &)
78
+ return unless can_en_passant?(color)
79
+
80
+ rank_offset = color == :white ? -1 : 1
81
+ [1, -1].each do |file_offset|
82
+ square = position.en_passant_target.offset(file_offset, rank_offset)
83
+ next unless square.valid? && board.get(square) == Piece[color, :pawn]
84
+
85
+ yield EnPassantEvent[color, square, position.en_passant_target]
86
+ end
87
+ end
88
+
89
+ def each_castling_event(color)
90
+ CastlingData::SIDES.each do |side|
91
+ next unless castling_available?(color, side)
92
+
93
+ yield CastlingEvent[color, side]
94
+ end
95
+ end
96
+
97
+ def move_should_promote?(piece, target_pos)
98
+ piece.type == :pawn &&
99
+ ((piece.color == :white && target_pos.rank == 8) ||
100
+ (piece.color == :black && target_pos.rank == 1))
101
+ end
102
+
103
+ def can_en_passant?(color)
104
+ position.en_passant_target && (
105
+ (color == :white && position.en_passant_target.rank == 6) ||
106
+ (color == :black && position.en_passant_target.rank == 3)
107
+ )
108
+ end
109
+
110
+ def castling_available?(color, side)
111
+ has_rights = @position.castling_rights.sides(color).public_send(side)
112
+ return false unless has_rights
113
+
114
+ king_is_attacked = CastlingData.king_path(color, side).any? do |sq|
115
+ square_attacked?(sq, position.other_color)
116
+ end
117
+ path_is_clear = CastlingData.intermediate_squares(color, side).all? do |sq|
118
+ board.get(sq).nil?
119
+ end
120
+
121
+ !king_is_attacked && path_is_clear
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'immutable'
4
+ require_relative 'state'
5
+ require_relative 'history'
6
+ require_relative 'legal_moves_helper'
7
+ require_relative '../data_definitions/square'
8
+ require_relative '../data_definitions/position'
9
+ require_relative '../data_definitions/primitives/colors'
10
+
11
+ module ChessEngine
12
+ module Game
13
+ # Provides derived information about the current game state.
14
+ #
15
+ # `Query` acts as the single entry point for all game-related queries.
16
+ # It exposes methods for:
17
+ # - **legal moves enumeration** (`legal_moves(color)`)
18
+ # - **Check and checkmate detection** (`in_check?`, `in_checkmate?`)
19
+ # - **draw detection** ( `must_draw?`, `in_draw?`, and detailed draw queries like `stalemate?` )
20
+ # - **Pieces and squares relations** (`piece_attacking?`, `piece_can_move?`, `square_attacked?`)
21
+ class Query
22
+ include LegalMovesHelper
23
+
24
+ INVALID_ARGUMENT = :invalid
25
+ attr_reader :position, :history
26
+
27
+ def initialize(position, history = History.start)
28
+ unless position.is_a?(Position) && history.is_a?(History)
29
+ raise ArgumentError,
30
+ "One or more invalid arguments for Game::Query: #{position}, #{history}"
31
+ end
32
+
33
+ @position = position
34
+ @history = history
35
+ end
36
+
37
+ def with(position: @position, history: @history)
38
+ self.class.new(position, history)
39
+ end
40
+
41
+ def state
42
+ @state ||= State.new(position: position, history: history)
43
+ end
44
+
45
+ # For easier access
46
+ def board = position.board
47
+ def position_signatures = history.position_signatures
48
+
49
+ # Determine whether a piece at square "from" can move to "to" without capturing,
50
+ # not taking into account other considerations like pins.
51
+ def piece_can_move?(from, to)
52
+ return INVALID_ARGUMENT unless valid_square?(from) && valid_square?(to)
53
+
54
+ piece = board.get(from)
55
+ board.find_pieces.include?(piece) && board.get(to).nil? && piece.moves(board, from).include?(to)
56
+ end
57
+
58
+ # Determines if a piece at the given square is attacking a target square.
59
+ def piece_attacking?(from, target_square)
60
+ return INVALID_ARGUMENT unless valid_square?(from) && valid_square?(target_square)
61
+
62
+ piece = board.get(from)
63
+ target_piece = board.get(target_square)
64
+ return false unless piece && piece.color != target_piece&.color
65
+
66
+ piece.threatened_squares(board, from).include?(target_square)
67
+ end
68
+
69
+ # Determines whether the given square is attacked by a piece of the specified color
70
+ def square_attacked?(attacked_square, color = @position.other_color)
71
+ return INVALID_ARGUMENT unless valid_square?(attacked_square) && Colors.valid?(color)
72
+
73
+ other_pieces_squares = board.pieces_with_squares(color: color)
74
+ other_pieces_squares.any? do |_p, attacking_square|
75
+ piece_attacking?(attacking_square, attacked_square)
76
+ end
77
+ end
78
+
79
+ # returns true if the king of the specified color is in check
80
+ def in_check?(color = @position.current_color)
81
+ return INVALID_ARGUMENT unless Colors.valid?(color)
82
+
83
+ _k, king_square = board.pieces_with_squares(color: color, type: :king).first
84
+ square_attacked?(king_square, Colors.flip(color))
85
+ end
86
+
87
+ # returns true if the king of the specified color is in checkmate
88
+ def in_checkmate?(color = @position.current_color)
89
+ return INVALID_ARGUMENT unless Colors.valid?(color)
90
+
91
+ in_check?(color) && legal_moves(color).none?
92
+ end
93
+
94
+ # Returns true if the game must end in a draw
95
+ def must_draw?
96
+ stalemate? || insufficient_material? || fivefold_repetition?
97
+ end
98
+
99
+ # returns true if the current player can request a draw to force the game to end
100
+ def can_draw?
101
+ threefold_repetition? || fifty_move_rule?
102
+ end
103
+
104
+ # returns true if the game is in stalemate
105
+ def stalemate?
106
+ !in_check? && legal_moves(@position.current_color).none?
107
+ end
108
+
109
+ # According to FIDE rules, as listed here:
110
+ # https://www.chess.com/terms/draw-chess#dead-position
111
+ def insufficient_material?
112
+ white_types = board.find_pieces(color: :white).map(&:type)
113
+ black_types = board.find_pieces(color: :black).map(&:type)
114
+
115
+ INSUFFICIENT_COMBINATIONS.any? do |combo|
116
+ match_combination?(white_types, black_types, combo) ||
117
+ match_combination?(black_types, white_types, combo)
118
+ end
119
+ end
120
+
121
+ # Added by FIDE in 2014
122
+ def fivefold_repetition?
123
+ @history.position_signatures.fetch(@position.signature, 0) >= 5
124
+ end
125
+
126
+ def threefold_repetition?
127
+ @history.position_signatures.fetch(@position.signature, 0) >= 3
128
+ end
129
+
130
+ def fifty_move_rule?
131
+ @position.halfmove_clock >= 100
132
+ end
133
+
134
+ INSUFFICIENT_COMBINATIONS = [
135
+ [%i[king], %i[king]],
136
+ [%i[king bishop], %i[king]],
137
+ [%i[king knight], %i[king]],
138
+ [%i[king bishop], %i[king bishop], :same_color_bishops]
139
+ ].freeze
140
+
141
+ private
142
+
143
+ # Match piece type combination for #insufficient_material?
144
+ def match_combination?(types1, types2, combo)
145
+ pattern1, pattern2, condition = combo
146
+ return false unless types1.sort == pattern1.sort && types2.sort == pattern2.sort
147
+
148
+ condition == :same_color_bishops ? same_color_bishops? : true
149
+ end
150
+
151
+ # Returns true if both sides have bishops on the same color squares
152
+ # (only relevant when each side has exactly one bishop)
153
+ # Used in #insufficient_material?
154
+ def same_color_bishops?
155
+ bishop_pos1 = board.pieces_with_squares(color: :white, type: :bishop).first&.last
156
+ bishop_pos2 = board.pieces_with_squares(color: :black, type: :bishop).first&.last
157
+ return false unless bishop_pos1 && bishop_pos2
158
+
159
+ file_distance, rank_distance = bishop_pos1.distance(bishop_pos2)
160
+ (file_distance + rank_distance).even?
161
+ end
162
+
163
+ def valid_square?(square)
164
+ square.is_a?(Square) && square.valid?
165
+ end
166
+ end
167
+ end
168
+ end