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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'primitives/movement'
4
+ require_relative 'primitives/colors'
5
+ require_relative 'primitives/core_notation'
6
+
7
+ module ChessEngine
8
+ # Represents a single chess piece.
9
+ #
10
+ # An invalid `Piece` can be created but must not be used, as this will cause an error.
11
+ # Ensure validity with `#valid?` before usage.
12
+ class Piece
13
+ include Movement
14
+
15
+ ### Piece types
16
+ TYPES = %i[king queen rook bishop knight pawn].freeze
17
+
18
+ ### Promotable piece types
19
+ PROMOTION_TYPES = %i[queen knight rook bishop].freeze
20
+
21
+ attr_reader :color, :type
22
+
23
+ def initialize(color, type)
24
+ @color = color
25
+ @type = type
26
+ end
27
+
28
+ # Returns all valid movement destinations for this piece, excluding captures.
29
+ def moves(board, square)
30
+ each_potential_move(board, square, is_attacking: false)
31
+ end
32
+
33
+ # Returns all squares this piece *geometrically threatens*,
34
+ # regardless of whether those squares are occupied or legally capturable.
35
+ #
36
+ # This is used for threat detection like check or pins.
37
+ def threatened_squares(board, square)
38
+ each_potential_move(board, square, is_attacking: true)
39
+ end
40
+
41
+ def valid?
42
+ Colors.valid?(color) && TYPES.include?(type)
43
+ end
44
+
45
+ private
46
+
47
+ # Yields every square this piece could move to or attack, depending on mode.
48
+ def each_potential_move(board, square, is_attacking:, &)
49
+ return enum_for(__method__, board, square, is_attacking: is_attacking) unless block_given?
50
+
51
+ yielded = false
52
+ yield_special_moves(board, square, is_attacking) do |move|
53
+ yielded = true
54
+ yield move
55
+ end
56
+
57
+ return if yielded
58
+
59
+ deltas = adjust_for_color(is_attacking ? attacks_deltas : base_deltas)
60
+ deltas.each do |delta|
61
+ walk_deltas(delta, board, square, is_attacking: is_attacking, &)
62
+ end
63
+ end
64
+
65
+ # Yields any special-case movement squares defined for this piece,
66
+ # such as pawn's initial double-step. Only applied in non-attacking context.
67
+ def yield_special_moves(board, square, is_attacking)
68
+ return enum_for(__method__, board, square, is_attacking) unless block_given?
69
+ return if !movement[:special_moves] || is_attacking
70
+
71
+ movement[:special_moves]&.each do |special|
72
+ next unless special[:condition].call(self, square)
73
+
74
+ path = adjust_for_color(special[:path])
75
+ current = square
76
+ path.each do |delta|
77
+ current = square.offset(*delta)
78
+ break if !current.valid? || board.get(current)
79
+
80
+ yield current
81
+ end
82
+ end
83
+ end
84
+
85
+ # Walks a vector across the board and yields each step until blocked or invalid.
86
+ def walk_deltas(delta, board, square, is_attacking:)
87
+ return enum_for(__method__, delta, board, square, is_attacking: is_attacking) unless block_given?
88
+
89
+ current_square = square
90
+
91
+ loop do
92
+ new_square = current_square.offset(*delta)
93
+ break unless new_square.valid?
94
+
95
+ blocker = board.get(new_square)
96
+
97
+ if blocker
98
+ yield new_square if is_attacking
99
+ break
100
+ else
101
+ yield new_square
102
+ end
103
+
104
+ break unless movement[:repeat]
105
+
106
+ current_square = new_square
107
+ end
108
+ end
109
+
110
+ def movement
111
+ MOVEMENT[@type]
112
+ end
113
+
114
+ def base_deltas
115
+ movement[:moves] || []
116
+ end
117
+
118
+ def attacks_deltas
119
+ movement[:attacks] || base_deltas
120
+ end
121
+
122
+ # Flips rank deltas for black pieces to account for orientation
123
+ def adjust_for_color(deltas)
124
+ @color == :black ? deltas.map { |f, r| [f, -r] } : deltas
125
+ end
126
+
127
+ public
128
+
129
+ def to_s
130
+ return "#<Piece (INVALID) color=#{color.inspect} type=#{type.inspect}>" unless valid?
131
+
132
+ CoreNotation.piece_to_str(self)
133
+ end
134
+
135
+ # For cleaner test messages
136
+ def inspect
137
+ to_s
138
+ end
139
+
140
+ # For making `Piece` a value object
141
+ def ==(other)
142
+ other.is_a?(Piece) && color == other.color && type == other.type
143
+ end
144
+
145
+ def eql?(other)
146
+ self == other
147
+ end
148
+
149
+ def hash
150
+ [color, type].hash
151
+ end
152
+
153
+ # For `Piece[color, type]` syntax.
154
+ # Makes it clearer that this is a value object, similar to Data
155
+ def self.[](color, type)
156
+ new(color, type)
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'board'
4
+ require_relative 'primitives/colors'
5
+ require_relative 'primitives/core_notation'
6
+ require_relative 'components/castling_rights'
7
+
8
+ module ChessEngine
9
+ # Namespace for `Position` and related stuff
10
+ module PositionModule
11
+ # Immutable container for all the data about the current chess position:
12
+ # board layout, active color, en passant target, castling rights, halfmove clock, and fullmove number.
13
+ Position = Data.define(
14
+ :board,
15
+ :current_color,
16
+ :en_passant_target,
17
+ :castling_rights,
18
+ :halfmove_clock,
19
+ :fullmove_number
20
+ ) do
21
+ def self.start
22
+ Position[
23
+ board: Board.start,
24
+ current_color: :white,
25
+ en_passant_target: nil,
26
+ castling_rights: CastlingRights.start,
27
+ halfmove_clock: 0,
28
+ fullmove_number: 1
29
+ ]
30
+ end
31
+
32
+ def self.from_fen(str)
33
+ FENConverter.from_fen(str)
34
+ end
35
+
36
+ def to_fen
37
+ FENConverter.to_fen(self)
38
+ end
39
+
40
+ # An identifier for indicating whether positions are identical in the context of position repetitions,
41
+ # as used for threefold/fivefold repetition detection.
42
+ def signature
43
+ [
44
+ board,
45
+ current_color,
46
+ en_passant_target,
47
+ castling_rights
48
+ ].hash
49
+ end
50
+
51
+ def other_color
52
+ Colors.flip(current_color)
53
+ end
54
+ end
55
+
56
+ # Converts between `Position` and FEN.
57
+ module FENConverter
58
+ extend self
59
+
60
+ def from_fen(str)
61
+ arr = str.split
62
+ raise ArgumentError, 'Not a valid FEN' unless arr.size == 6
63
+
64
+ board_str, color_str, castling_rights_str, en_passant_target_str, halfmove_clock_str, fullmove_number_str = arr
65
+
66
+ board = fen_to_board(board_str)
67
+ color = case color_str
68
+ when 'w' then :white
69
+ when 'b' then :black
70
+ else raise ArgumentError, "Not a valid color #{color_str}"
71
+ end
72
+ castling_rights = CoreNotation.str_to_castling_rights(castling_rights_str)
73
+ en_passant_target = en_passant_target_str == '-' ? nil : CoreNotation.str_to_square(en_passant_target_str)
74
+
75
+ Position[
76
+ board: board,
77
+ current_color: color,
78
+ en_passant_target: en_passant_target,
79
+ castling_rights: castling_rights,
80
+ halfmove_clock: halfmove_clock_str.to_i,
81
+ fullmove_number: fullmove_number_str.to_i
82
+ ]
83
+ end
84
+
85
+ def to_fen(pos)
86
+ board_str = board_to_fen(pos.board)
87
+ color_str = pos.current_color == :white ? 'w' : 'b'
88
+ castling_rights_str = CoreNotation.castling_rights_to_str(pos.castling_rights)
89
+ en_passant_target_str = pos.en_passant_target.nil? ? '-' : CoreNotation.square_to_str(pos.en_passant_target)
90
+
91
+ "#{board_str} #{color_str} #{castling_rights_str} #{en_passant_target_str} #{pos.halfmove_clock} #{pos.fullmove_number}"
92
+ end
93
+
94
+ private
95
+
96
+ def fen_to_board(str)
97
+ board_array = str.split('/').reverse.map do |row|
98
+ row.chars.map do |piece|
99
+ if ('1'..'8').include?(piece)
100
+ [nil] * piece.to_i
101
+ else
102
+ CoreNotation.str_to_piece(piece)
103
+ end
104
+ end
105
+ end
106
+ Board.from_flat_array(board_array.flatten)
107
+ end
108
+
109
+ def board_to_fen(board)
110
+ ranks = board.each_rank.map do |rank|
111
+ rank_to_fen(rank)
112
+ end
113
+ ranks.reverse.join('/')
114
+ end
115
+
116
+ def rank_to_fen(rank)
117
+ rank_str = ''
118
+ nil_count = 0
119
+ rank.each do |piece|
120
+ if piece.nil?
121
+ nil_count += 1
122
+ else
123
+ rank_str += nil_count.to_s unless nil_count.zero?
124
+ rank_str += CoreNotation.piece_to_str(piece)
125
+ nil_count = 0
126
+ end
127
+ end
128
+ rank_str += nil_count.to_s unless nil_count.zero?
129
+ rank_str
130
+ end
131
+ end
132
+ end
133
+
134
+ CastlingSides = PositionModule::CastlingSides
135
+ CastlingRights = PositionModule::CastlingRights
136
+ Position = PositionModule::Position
137
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'immutable'
4
+ require_relative '../square'
5
+
6
+ module ChessEngine
7
+ # Movement squares for castling pieces, based on the color and side
8
+ module CastlingData
9
+ SIDES = %i[kingside queenside].freeze
10
+
11
+ FILES = Immutable.from(
12
+ {
13
+ kingside: {
14
+ king_to: :g,
15
+ rook_from: :h,
16
+ rook_to: :f,
17
+ king_path: %i[e f g],
18
+ intermediate_squares: %i[f g]
19
+ },
20
+ queenside: {
21
+ king_to: :c,
22
+ rook_from: :a,
23
+ rook_to: :d,
24
+ king_path: %i[e d c],
25
+ intermediate_squares: %i[b c d]
26
+ }
27
+ }
28
+ ).freeze
29
+
30
+ RANK_FOR_COLOR = { white: 1, black: 8 }.freeze
31
+
32
+ def self.rank(color)
33
+ RANK_FOR_COLOR.fetch(color)
34
+ end
35
+
36
+ def self.king_from(color, _side = nil)
37
+ Square[:e, rank(color)]
38
+ end
39
+
40
+ def self.king_to(color, side)
41
+ Square[FILES.fetch(side)[:king_to], rank(color)]
42
+ end
43
+
44
+ def self.king_path(color, side)
45
+ FILES.fetch(side)[:king_path].map { |f| Square[f, rank(color)] }
46
+ end
47
+
48
+ def self.rook_from(color, side)
49
+ Square[FILES.fetch(side)[:rook_from], rank(color)]
50
+ end
51
+
52
+ def self.rook_to(color, side)
53
+ Square[FILES.fetch(side)[:rook_to], rank(color)]
54
+ end
55
+
56
+ # Every square passed through either by the king or the rook, not including starting squares
57
+ def self.intermediate_squares(color, side)
58
+ FILES.fetch(side)[:intermediate_squares].map { |f| Square[f, rank(color)] }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChessEngine
4
+ # Defines player colors and helpers.
5
+ module Colors
6
+ COLORS = %i[white black].freeze
7
+
8
+ module_function
9
+
10
+ # Returns the opposite color (:white <-> :black)
11
+ def flip(color)
12
+ case color
13
+ when :white then :black
14
+ when :black then :white
15
+ else raise ArgumentError, "Invalid color: #{color.inspect}"
16
+ end
17
+ end
18
+
19
+ # Alias
20
+ def other(color) = flip(color)
21
+
22
+ def valid?(color) = COLORS.include?(color)
23
+ def each(&) = COLORS.each(&)
24
+ def to_string(color) = color.to_s.capitalize
25
+ end
26
+ end
@@ -0,0 +1,111 @@
1
+ require_relative '../piece'
2
+ require_relative '../square'
3
+ require_relative '../components/castling_rights'
4
+
5
+ module ChessEngine
6
+ # Represents the components of chess notation that are shared by common notation formats.
7
+ # Includes utilities for importing and exporting those components.
8
+ module CoreNotation
9
+ module_function
10
+
11
+ # -- Mappings ---
12
+ PIECE_MAP = {
13
+ king: 'K',
14
+ queen: 'Q',
15
+ rook: 'R',
16
+ bishop: 'B',
17
+ knight: 'N',
18
+ pawn: 'P'
19
+ }.freeze
20
+
21
+ CASTLING_MAP = {
22
+ %i[white kingside] => 'K',
23
+ %i[white queenside] => 'Q',
24
+ %i[black kingside] => 'k',
25
+ %i[black queenside] => 'q'
26
+ }.freeze
27
+
28
+ FILES_STR = Square::FILES.map(&:to_s).freeze
29
+ RANKS_STR = Square::RANKS.map(&:to_s).freeze
30
+
31
+ # Derived mappings
32
+ PIECE_MAP_REVERSE = PIECE_MAP.invert.freeze
33
+ CASTLING_MAP_REVERSE = CASTLING_MAP.invert.freeze
34
+
35
+ # --- Pieces ---
36
+ def piece_to_str(piece)
37
+ raise ArgumentError, "Not a valid Piece: #{piece}" unless piece.is_a?(Piece) && piece.valid?
38
+
39
+ symbol = PIECE_MAP[piece.type]
40
+ piece.color == :black ? symbol.downcase : symbol
41
+ end
42
+
43
+ def str_to_piece(str)
44
+ color = str == str.upcase ? :white : :black
45
+ type = PIECE_MAP_REVERSE.fetch(str.upcase) do
46
+ raise ArgumentError, "Not a valid string: #{str}"
47
+ end
48
+
49
+ Piece[color, type]
50
+ end
51
+
52
+ # --- Squares ---
53
+ def square_to_str(square)
54
+ raise ArgumentError, "Not a valid Square: #{square}" unless square.is_a?(Square) && square.valid?
55
+
56
+ "#{square.file}#{square.rank}"
57
+ end
58
+
59
+ def str_to_square(str)
60
+ file, rank = nil
61
+ if str.size == 2
62
+ file, rank = str.chars
63
+ raise ArgumentError, "Not a valid string: #{str}" unless FILES_STR.include?(file) && RANKS_STR.include?(rank)
64
+ elsif FILES_STR.include?(str)
65
+ file = str
66
+ elsif RANKS_STR.include?(str)
67
+ rank = str
68
+ else
69
+ raise ArgumentError, "Not a valid string: #{str}"
70
+ end
71
+
72
+ Square[file&.to_sym, rank&.to_i]
73
+ end
74
+
75
+ # --- Castling rights ---
76
+ def castling_rights_to_str(rights)
77
+ raise ArgumentError, "Not a CastlingRights: #{rights}" unless rights.is_a?(CastlingRights)
78
+
79
+ result = CASTLING_MAP.reduce('') do |result, (color_side, char)|
80
+ color, side = color_side
81
+ if rights.sides(color).side?(side)
82
+ result + char
83
+ else
84
+ result
85
+ end
86
+ end
87
+
88
+ result.empty? ? '-' : result
89
+ end
90
+
91
+ def str_to_castling_rights(str)
92
+ rights = CastlingRights.none
93
+ return rights if str == '-'
94
+
95
+ allowed_chars = CASTLING_MAP_REVERSE.keys
96
+ str.chars.each do |char|
97
+ raise ArgumentError, "Not valid string for castling rights: #{str}" unless allowed_chars.include?(char)
98
+
99
+ color, side = CASTLING_MAP_REVERSE[char]
100
+ sides = rights.sides(color).with(side => true)
101
+ rights = rights.with(color => sides)
102
+
103
+ # Remove all characters up to and including the found character
104
+ # Prevents repeating characters and incorrect char order
105
+ allowed_chars = allowed_chars.drop_while { |c| c != char }.drop(1)
106
+ end
107
+
108
+ rights
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'immutable'
4
+
5
+ module ChessEngine
6
+ class Piece
7
+ # Describes the movement pattern of a piece.
8
+ # Does not account for special moves.
9
+ # Each entry consists of:
10
+ # - moves: movement deltas in [file_delta, rank_delta] format (x -> file, y -> rank)
11
+ # - repeat: a boolean indicating whether the piece can move repeatedly in a direction
12
+ # For pawns (and optionally other pieces in variants), additional keys may be present:
13
+ # - attacks: movement deltas for capturing (e.g., pawn diagonal captures)
14
+ # - special_moves: an array of special move definitions.
15
+ # Each special move is a hash with:
16
+ # - path: an array of deltas (each [file_delta, rank_delta]) describing the move sequence
17
+ # - condition: a lambda that takes the piece and returns true if the move is allowed
18
+ # (e.g., only on the pawn's first move)
19
+ module Movement
20
+ straight = [[0, 1], [0, -1], [1, 0], [-1, 0]]
21
+ diagonal = [[1, 1], [-1, 1], [1, -1], [-1, -1]]
22
+ knight = [
23
+ [2, 1], [1, 2], [-1, 2], [-2, 1],
24
+ [2, -1], [1, -2], [-1, -2], [-2, -1]
25
+ ]
26
+
27
+ MOVEMENT = Immutable.from(
28
+ {
29
+ king: { moves: straight + diagonal, repeat: false },
30
+ queen: { moves: straight + diagonal, repeat: true },
31
+ rook: { moves: straight, repeat: true },
32
+ bishop: { moves: diagonal, repeat: true },
33
+ knight: { moves: knight, repeat: false },
34
+ pawn: {
35
+ moves: [[0, 1]],
36
+ attacks: [[1, 1], [-1, 1]],
37
+ repeat: false,
38
+ special_moves: [
39
+ {
40
+ path: [[0, 1], [0, 2]],
41
+ condition: lambda { |piece, square|
42
+ (piece.color == :white && square.rank == 2) ||
43
+ (piece.color == :black && square.rank == 7)
44
+ }
45
+ }
46
+ ]
47
+ }
48
+ }
49
+ )
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module ChessEngine
6
+ # Represents chessboard squares, each with a rank and a file,
7
+ # and provides methods for validation, conversion, and manipulation of squares within the chess engine.
8
+ #
9
+ # An invalid `Square` (e.g., off the board) can be created but must not be used, as this will cause an error.
10
+ # Ensure validity with `#valid?` before usage.
11
+ class Square
12
+ FILES = (:a..:h).to_a.freeze
13
+ RANKS = (1..8).to_a.freeze
14
+
15
+ attr_reader :file, :rank
16
+
17
+ def initialize(file, rank)
18
+ @file = file
19
+ @rank = rank
20
+ end
21
+
22
+ def self.from_index(row, col)
23
+ return new(nil, nil) unless (0..7).cover?(row) && (0..7).cover?(col)
24
+
25
+ new(FILES[col], RANKS[row])
26
+ end
27
+
28
+ # Produces a standard array representation of [row, column]
29
+ def to_a
30
+ return InvalidSquareError unless valid?
31
+
32
+ [@rank - 1, FILES.index(@file)]
33
+ end
34
+
35
+ def offset(file_delta, rank_delta)
36
+ row, col = to_a
37
+ row += rank_delta
38
+ col += file_delta
39
+ Square.from_index(row, col)
40
+ end
41
+
42
+ # Returns the distance between self and the given Square object.
43
+ # The result is of the format [file_distance, rank_distance].
44
+ # For example, the distance from b2 to d2 is [2, 0].
45
+ def distance(other)
46
+ raise InvalidSquareError unless valid? && other&.valid?
47
+
48
+ file_distance = (FILES.index(file) - FILES.index(other.file)).abs
49
+ rank_distance = (rank - other.rank).abs
50
+ [file_distance, rank_distance]
51
+ end
52
+
53
+ # Returns true if self is partially included in the other square.
54
+ # For this method, self doesn't have to be a full, valid square.
55
+ # It can have any of the following: specific rank and file,
56
+ # specific file (nil rank), specific rank (nil file), or both being nil.
57
+ # `other` is a complete, valid square.
58
+ def matches?(other)
59
+ file_matches = file.nil? || file == other.file
60
+ rank_matches = rank.nil? || rank == other.rank
61
+
62
+ file_matches && rank_matches
63
+ end
64
+
65
+ def valid?
66
+ FILES.include?(file) && RANKS.include?(rank)
67
+ end
68
+
69
+ def to_s
70
+ return "#<Square (INVALID) file=#{file.inspect}, rank=#{rank.inspect}>" unless valid?
71
+
72
+ "#{file}#{rank}"
73
+ end
74
+
75
+ # For cleaner test messages
76
+ def inspect
77
+ to_s
78
+ end
79
+
80
+ def ==(other)
81
+ other.is_a?(Square) && file == other.file && rank == other.rank
82
+ end
83
+
84
+ def eql?(other)
85
+ self == other
86
+ end
87
+
88
+ def hash
89
+ [file, rank].hash
90
+ end
91
+
92
+ # For `Square[file, rank]` syntax.
93
+ # Makes it clearer that this is a value object, similar to Data
94
+ def self.[](file, rank)
95
+ new(file, rank)
96
+ end
97
+ end
98
+ end