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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +83 -0
- data/lib/chess_engine/data_definitions/README.md +10 -0
- data/lib/chess_engine/data_definitions/board.rb +192 -0
- data/lib/chess_engine/data_definitions/components/castling_rights.rb +48 -0
- data/lib/chess_engine/data_definitions/components/persistent_array.rb +114 -0
- data/lib/chess_engine/data_definitions/events.rb +174 -0
- data/lib/chess_engine/data_definitions/piece.rb +159 -0
- data/lib/chess_engine/data_definitions/position.rb +137 -0
- data/lib/chess_engine/data_definitions/primitives/castling_data.rb +61 -0
- data/lib/chess_engine/data_definitions/primitives/colors.rb +26 -0
- data/lib/chess_engine/data_definitions/primitives/core_notation.rb +111 -0
- data/lib/chess_engine/data_definitions/primitives/movement.rb +52 -0
- data/lib/chess_engine/data_definitions/square.rb +98 -0
- data/lib/chess_engine/engine.rb +299 -0
- data/lib/chess_engine/errors.rb +35 -0
- data/lib/chess_engine/event_handlers/base_event_handler.rb +112 -0
- data/lib/chess_engine/event_handlers/castling_event_handler.rb +48 -0
- data/lib/chess_engine/event_handlers/en_passant_event_handler.rb +59 -0
- data/lib/chess_engine/event_handlers/init.rb +41 -0
- data/lib/chess_engine/event_handlers/move_event_handler.rb +144 -0
- data/lib/chess_engine/formatters/eran_formatters.rb +71 -0
- data/lib/chess_engine/formatters/init.rb +19 -0
- data/lib/chess_engine/formatters/validation.rb +54 -0
- data/lib/chess_engine/game/history.rb +25 -0
- data/lib/chess_engine/game/init.rb +15 -0
- data/lib/chess_engine/game/legal_moves_helper.rb +126 -0
- data/lib/chess_engine/game/query.rb +168 -0
- data/lib/chess_engine/game/state.rb +198 -0
- data/lib/chess_engine/parsers/eran_parser.rb +85 -0
- data/lib/chess_engine/parsers/identity_parser.rb +18 -0
- data/lib/chess_engine/parsers/init.rb +21 -0
- data/lib/chess_engine_rb.rb +46 -0
- 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
|