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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 00dbb28085362bcf277a8ee0da5ff60b7676485a8a24e2354ae25a6925438c36
4
+ data.tar.gz: 35eb6625b09d3773c7bbafac6de105d39aa54cff374e9ed547dae91a2d766ad2
5
+ SHA512:
6
+ metadata.gz: 90b584edd11dc6e21186536305ce6a9f8d55198741cd6aee1a40a56689d024d14d3eb2b618b65e90c0886fc19817c21493ae8bdd50201868d4d6fcd72e0bbc1a
7
+ data.tar.gz: c7d92097b4c1d562acd39bdc8fe1a893d018393ac512d013c3c6557270edcafe747254887752ad0722d94886dcbccca51aeb7cd882f31debc86c08884d7cc083
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Nadav Levi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # Ruby Chess Engine
2
+ A modular, deterministic chess engine built around immutable objects.
3
+ Cleanly expresses chess concepts in code and designed for easy integration with any UI.
4
+
5
+ > ⚠️ Note: This is not a competitive chess engine like Stockfish.
6
+ While AI features could be added in the future, the core purpose of this project is to provide a ruby gem for cleanly representing chess in code.
7
+
8
+ # Features
9
+ - UI-agnostic design
10
+ - Fully immutable game state representation
11
+ - Modular: components can be used separately or coordinated via the `Engine` class
12
+ - Chess concepts map cleanly to code: squares, pieces, board, rules, etc
13
+ - Pluggable notation systems for both parsing and formatting: a custom notation called ERAN is the default,
14
+ but parsers & formatters for any other notation system(SAN, LAN, etc) can be implemented and plugged in instead
15
+ - FEN import and export
16
+
17
+ # Examples
18
+ ### Simple usage
19
+ ```ruby
20
+ require 'chess_engine_rb'
21
+
22
+ engine = ChessEngine::Engine.new
23
+ engine.new_game
24
+ engine.play_turn('P e2-e4') # play a move
25
+ puts engine.status.board # prints the board
26
+ puts engine.to_fen # prints FEN
27
+ engine.from_fen('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1') # Load from FEN
28
+ ```
29
+
30
+ ### Using a listener
31
+ ```ruby
32
+ require 'chess_engine_rb'
33
+
34
+ class Listener
35
+ FORMATTER = ChessEngine::Formatters::ERANShortFormatter
36
+
37
+ def on_game_update(update)
38
+ if update.failure?
39
+ puts "Update failed: #{update.error}"
40
+ elsif update.game_ended?
41
+ puts "Game over! Reason: #{update.endgame_status.cause}"
42
+ elsif update.offered_draw
43
+ puts 'Draw has been offered'
44
+ else
45
+ puts "Move accepted: #{FORMATTER.call(update.event)}"
46
+ end
47
+ end
48
+ end
49
+
50
+ engine = ChessEngine::Engine.new
51
+ engine.add_listener(Listener.new)
52
+ engine.play_turn('P e7-e6') # => Move accepted: P e7-e6
53
+ engine.play_turn('HA!') # => Update failed: invalid_notation
54
+ engine.offer_draw # => Draw has been offered
55
+ engine.resign # => Game over! Reason: resignation
56
+ ```
57
+
58
+ For more complete examples, see the examples folder.
59
+
60
+ # Installation
61
+ ## From RubyGems (recommended)
62
+ - Install Ruby (version >= 3.4.1) if you haven't already (on Windows, you can use [RubyInstaller](https://rubyinstaller.org/downloads/))
63
+ - Install the gem: `gem install chess_engine_rb`
64
+ - If you want to see the engine in action, download the example CLI `examples/chess_cli.rb` and run it: `ruby chess_cli.rb`
65
+
66
+ ## From source
67
+ - Make sure Ruby (version >= 3.4.1) and Bundler are installed
68
+ - Clone the repository and run `bundle install` from the project's root directory
69
+
70
+ # More information
71
+ - See the [architectural overview](docs/architecture.md)
72
+ - Browse the docs and examples for more information
73
+
74
+ # Possible future additions
75
+ - Move undo
76
+ - SAN and LAN parsers/formatters
77
+ - PGN import/export
78
+ - Performance improvements
79
+ - Comprehensive perft testing
80
+ - Basic AI
81
+
82
+ # License
83
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,10 @@
1
+ # `data_definitions/`
2
+
3
+ This folder contains the core data types used within the engine.
4
+ Those include immutable value objects that model engine entities, and primitive static definitions.
5
+
6
+ # structure
7
+ - **Top-level:** meaningful value objects - `Piece`, `Square`, `Board`, `Position`, and events.
8
+ - **Subfolders:**
9
+ - `primitives/` - static definitions of core concepts, such as colors and core notation.
10
+ - `components/` - internal dependencies of the top-level types, such as the persistent array underpinning `Board`.
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'piece'
4
+ require_relative 'square'
5
+ require_relative '../errors'
6
+ require_relative 'components/persistent_array'
7
+
8
+ module ChessEngine
9
+ # `Board` is an immutable chessboard representation.
10
+ # Each square is mapped to either a piece or nil, using `Square` objects for coordinates.
11
+ # Provides query methods (e.g., `#get`, `#pieces_with_squares`) to inspect board state,
12
+ # and manipulation methods that return new `Board` instances with the desired changes.
13
+ # Designed for safe, functional-style updates and efficient state sharing.
14
+ class Board
15
+ SIZE = 8 # the board dimensions
16
+
17
+ # Constructs a `Board` from a flat array of 64 items.
18
+ # Each item's index maps to a board square as follows:
19
+ # 0 -> a1, 2 -> b1, ... 8 -> a2, ... 63 -> h8
20
+ # Each item should be a `Piece` or nil, representing the contents of that square.
21
+ def self.from_flat_array(values)
22
+ raise ArgumentError, 'Expected 64 elements' unless values.size == SIZE * SIZE
23
+ raise ArgumentError, 'Expected nil or Piece objects' unless values.all? { it.nil? || it.is_a?(Piece) }
24
+
25
+ array = PersistentArray.from_values(values)
26
+ new(array)
27
+ end
28
+
29
+ # An empty board
30
+ def self.empty
31
+ from_flat_array(Array.new(SIZE * SIZE))
32
+ end
33
+
34
+ # A board with all pieces set up at their starting squares
35
+ def self.start
36
+ back_row = %i[rook knight bishop queen king bishop knight rook]
37
+ ranks = [
38
+ back_row.map { |t| Piece.new(:white, t) }, # Rank 1
39
+ Array.new(8) { Piece.new(:white, :pawn) }, # Rank 2
40
+ Array.new(4) { Array.new(8) }, # Ranks 3–6
41
+ Array.new(8) { Piece.new(:black, :pawn) }, # Rank 7
42
+ back_row.map { |t| Piece.new(:black, t) } # Rank 8
43
+ ]
44
+
45
+ from_flat_array(ranks.flatten)
46
+ end
47
+
48
+ # Use only internally
49
+ def initialize(array)
50
+ @array = array
51
+ end
52
+
53
+ ######## Queries
54
+
55
+ # Get the piece at the given square, or nil if the square is unoccupied
56
+ def get(square)
57
+ index = square_to_index(square)
58
+ @array.get(index)
59
+ end
60
+
61
+ # Returns an array of all pieces, with their squares, matching the criteria.
62
+ # The result is an array of elements of the form: [piece, square]
63
+ # If type or color is nil, that attribute is ignored.
64
+ # Examples:
65
+ # All black pieces: find_pieces(color: :black)
66
+ # All pieces: find_pieces
67
+ # White rooks: find_pieces(type: :rook, color: :white)
68
+ def pieces_with_squares(color: nil, type: nil)
69
+ @array.filter_map.with_index do |piece, index|
70
+ next if piece.nil?
71
+
72
+ [piece, index_to_square(index)] if [nil, piece.type].include?(type) && [nil, piece.color].include?(color)
73
+ end
74
+ end
75
+
76
+ # Returns an array of all pieces matching the criteria.
77
+ # Internally delegates to `#pieces_with_squares`, stripping the squares.
78
+ def find_pieces(color: nil, type: nil)
79
+ pieces_with_squares(color: color, type: type).map(&:first)
80
+ end
81
+
82
+ def each_square_content(&)
83
+ return enum_for(__method__) unless block_given?
84
+
85
+ SIZE.times do |row|
86
+ SIZE.times do |col|
87
+ yield @array.get((row * SIZE) + col)
88
+ end
89
+ end
90
+ end
91
+
92
+ def each_rank(&)
93
+ return enum_for(__method__) unless block_given?
94
+
95
+ SIZE.times do |row|
96
+ yield SIZE.times.map { |col| @array.get((row * SIZE) + col) }
97
+ end
98
+ end
99
+
100
+ def each_file(&)
101
+ return enum_for(__method__) unless block_given?
102
+
103
+ SIZE.times do |col|
104
+ yield SIZE.times.map { |row| @array.get((row * SIZE) + col) }
105
+ end
106
+ end
107
+
108
+ ######## Manipulation
109
+
110
+ def move(from, to)
111
+ from_index = square_to_index from
112
+ piece = @array.get from_index
113
+ to_index = square_to_index to
114
+ raise BoardManipulationError, 'No piece to move' if piece.nil?
115
+ raise BoardManipulationError, 'Destination is already occupied' unless @array.get(to_index).nil?
116
+ raise BoardManipulationError, 'Cannot move to the same square' if from == to
117
+
118
+ Board.new(@array.set(from_index, nil).set(to_index, piece))
119
+ end
120
+
121
+ def remove(square)
122
+ index = square_to_index square
123
+ raise BoardManipulationError, 'Square is unoccupied' if get(square).nil?
124
+
125
+ Board.new(@array.set(index, nil))
126
+ end
127
+
128
+ # Inserts the given piece to an empty square
129
+ def insert(piece, square)
130
+ index = square_to_index(square)
131
+ raise ArgumentError, 'Not a valid piece' unless piece.is_a?(Piece)
132
+ raise BoardManipulationError, 'Square is occupied' unless @array.get(index).nil?
133
+
134
+ Board.new(@array.set(index, piece))
135
+ end
136
+
137
+ # For debugging mainly
138
+ def to_s # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
139
+ rows = []
140
+ rows << ' a b c d e f g h'
141
+ rows << ' ┌─────────────────┐'
142
+ (0...SIZE).each do |row|
143
+ row_str = "#{row + 1}│ "
144
+ (0...SIZE).each do |col|
145
+ index = square_to_index(Square.from_index(row, col))
146
+ piece = @array.get(index)
147
+ row_str += if piece
148
+ "#{piece} "
149
+ else
150
+ (row + col).odd? ? '□ ' : '■ '
151
+ end
152
+ end
153
+ rows << "#{row_str.chomp}│#{row + 1}"
154
+ end
155
+ rows << ' └─────────────────┘'
156
+ rows << ' a b c d e f g h'
157
+ rows.join("\n")
158
+ end
159
+
160
+ def inspect
161
+ "#<Board #{pieces_with_squares.map { |piece, pos| "#{piece}@#{pos}" }.join(', ')}>"
162
+ end
163
+
164
+ def ==(other)
165
+ other.is_a?(Board) && pieces_with_squares == other.pieces_with_squares
166
+ end
167
+
168
+ def eql?(other)
169
+ self == other
170
+ end
171
+
172
+ def hash
173
+ # Doesn't use the exact same hash as `#pieces_with_squares` to avoid clashes
174
+ [pieces_with_squares, 0].hash
175
+ end
176
+
177
+ private
178
+
179
+ def square_to_index(square)
180
+ raise ArgumentError, "#{square.inspect} is not a Square" unless square.is_a?(Square)
181
+ raise InvalidSquareError, "#{square.inspect} is not a valid square" unless square.valid?
182
+
183
+ row, col = square.to_a
184
+ (row * SIZE) + col
185
+ end
186
+
187
+ def index_to_square(index)
188
+ row, col = index.divmod(SIZE)
189
+ Square.from_index(row, col)
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChessEngine
4
+ module PositionModule
5
+ # Represents complete castling rights of both color for a certain position
6
+ CastlingRights = Data.define(
7
+ :white, :black
8
+ ) do
9
+ def self.start
10
+ new(CastlingSides.start, CastlingSides.start)
11
+ end
12
+
13
+ def self.none
14
+ new(CastlingSides.none, CastlingSides.none)
15
+ end
16
+
17
+ def sides(color)
18
+ case color
19
+ when :white then white
20
+ when :black then black
21
+ else
22
+ raise ArgumentError, "Invalid color: #{color.inspect}"
23
+ end
24
+ end
25
+ end
26
+
27
+ # Tracks whether each side still retains castling rights.
28
+ # Rights may be lost due to moving the king or rook, or other game events.
29
+ CastlingSides = Data.define(:kingside, :queenside) do
30
+ def self.start
31
+ new(true, true)
32
+ end
33
+
34
+ def self.none
35
+ new(false, false)
36
+ end
37
+
38
+ def side?(side)
39
+ case side
40
+ when :kingside then kingside
41
+ when :queenside then queenside
42
+ else
43
+ raise ArgumentError, "Invalid castling side: #{side.inspect}"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChessEngine
4
+ class Board
5
+ module PersistentArrayModule
6
+ SIZE = 64
7
+ BRANCHING_FACTOR = 8
8
+
9
+ # `PersistentArray` provides an immutable, tree-based fixed-size array.
10
+ # It's designed specifically for use by the `Board` class to support immutable updates,
11
+ # allowing efficient structural sharing of board state.
12
+ #
13
+ # Not intended as a general-purpose data structure outside of this context.
14
+ class PersistentArray
15
+ include Enumerable
16
+
17
+ # The public interface
18
+ def self.from_values(values)
19
+ raise ArgumentError, "Expected exactly #{SIZE} elements, got #{values.size}" unless values.size == SIZE
20
+
21
+ new(InternalNode.from_values(values))
22
+ end
23
+
24
+ # Do not use directly - intended to be private
25
+ def initialize(root)
26
+ @root = root
27
+ end
28
+
29
+ def get(index)
30
+ raise IndexError, "Index #{index} out of bounds" unless (0...SIZE).cover?(index)
31
+
32
+ @root.get(index, SIZE)
33
+ end
34
+
35
+ def set(index, new_value)
36
+ raise IndexError, "Index #{index} out of bounds" unless (0...SIZE).cover?(index)
37
+
38
+ PersistentArray.new(@root.set(index, new_value, SIZE))
39
+ end
40
+
41
+ def each(&)
42
+ @root.each(&)
43
+ end
44
+
45
+ def to_s
46
+ "[#{map(&:inspect).join(', ')}]"
47
+ end
48
+ end
49
+
50
+ # A PersistentArray node that holds references to other nodes
51
+ class InternalNode
52
+ def self.from_values(values)
53
+ chunk_size = values.size / BRANCHING_FACTOR
54
+ node_class = chunk_size == BRANCHING_FACTOR ? LeafNode : InternalNode
55
+ branches = Array.new(BRANCHING_FACTOR) do |i|
56
+ node_class.from_values(values[chunk_size * i, chunk_size])
57
+ end
58
+
59
+ InternalNode.new(branches)
60
+ end
61
+
62
+ def initialize(branches)
63
+ @branches = branches.freeze
64
+ end
65
+
66
+ def get(index, chunk_size)
67
+ new_chunk_size = chunk_size / BRANCHING_FACTOR
68
+ branch_index, inner_index = index.divmod(new_chunk_size)
69
+ @branches[branch_index].get(inner_index, new_chunk_size)
70
+ end
71
+
72
+ def set(index, value, chunk_size)
73
+ new_chunk_size = chunk_size / BRANCHING_FACTOR
74
+ branch_index, inner_index = index.divmod(new_chunk_size)
75
+ new_branches = @branches.dup
76
+ new_branches[branch_index] = new_branches[branch_index].set(inner_index, value, new_chunk_size)
77
+ InternalNode.new(new_branches)
78
+ end
79
+
80
+ def each(&)
81
+ @branches.each { |branch| branch.each(&) }
82
+ end
83
+ end
84
+
85
+ # A PersistentArray node that holds values
86
+ class LeafNode
87
+ # For compatibility with InternalNode
88
+ def self.from_values(values)
89
+ LeafNode.new(values)
90
+ end
91
+
92
+ def initialize(values)
93
+ @values = values.freeze
94
+ end
95
+
96
+ def get(index, _)
97
+ @values[index]
98
+ end
99
+
100
+ def set(index, value, _)
101
+ new_values = @values.dup
102
+ new_values[index] = value
103
+ LeafNode.new(new_values)
104
+ end
105
+
106
+ def each(&)
107
+ @values.each(&)
108
+ end
109
+ end
110
+ end
111
+
112
+ PersistentArray = PersistentArrayModule::PersistentArray
113
+ end
114
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'immutable'
4
+ require 'wholeable'
5
+ require_relative 'square'
6
+ require_relative 'piece'
7
+ require_relative 'primitives/colors'
8
+ require_relative 'primitives/castling_data'
9
+
10
+ module ChessEngine
11
+ # Events are immutable records representing game actions or state changes.
12
+ # They are produced by the parser (user intent) and by the engine (execution outcome).
13
+ #
14
+ # The parser may generate incomplete events.
15
+ # The event handler **must** populate all *required* fields, but **must not** populate *optional* fields.
16
+ #
17
+ # **NOTE:** The declaration of a field as optional is made explicitly within the event subclass definition.
18
+ module Events
19
+ # Base class for all events
20
+ class BaseEvent
21
+ include Wholeable[:assertions]
22
+
23
+ def initialize(assertions: nil)
24
+ # Assertions reflect the annotations sometimes appended to algebraic chess notation moves (e.g. "!", "?", "e.p.").
25
+ # Handlers must not depend on them for correctness.
26
+ # If assertions state plain falsehoods (e.g. claim check when not in check),
27
+ # the event may be considered invalid.
28
+ @assertions = assertions
29
+
30
+ # Get `BaseEvent#inspect` and `#to_s` specifically, since `Wholeable` overrides them
31
+ define_singleton_method(:inspect) do
32
+ BaseEvent.instance_method(:inspect).bind(self).call
33
+ end
34
+
35
+ define_singleton_method(:to_s) do
36
+ BaseEvent.instance_method(:to_s).bind(self).call
37
+ end
38
+ end
39
+
40
+ def inspect
41
+ filled, nils = to_h.partition { |_, v| !v.nil? }
42
+ filled_s = filled.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')
43
+ nils = nils.map(&:first)
44
+ parts = [
45
+ filled_s.empty? ? nil : filled_s,
46
+ nils.empty? ? nil : "nil: [#{nils.join(', ')}]"
47
+ ].compact
48
+ "#<#{self.class} #{parts.join(', ')}>"
49
+ end
50
+
51
+ def to_s = inspect
52
+
53
+ def with(*, **)
54
+ raise NotImplementedError, "#with doesn't work for wholeables defined with positional arguments"
55
+ end
56
+ end
57
+
58
+ # Move a piece from one square to another.
59
+ # `captured` and `promote_to` are optional - only for captures and promotion, respectively.
60
+ class MovePieceEvent < BaseEvent
61
+ include Wholeable[:piece, :from, :to, :captured, :promote_to]
62
+
63
+ def initialize(piece, from, to, captured = nil, promote_to = nil, **)
64
+ super(**)
65
+ @piece = piece
66
+ @from = from
67
+ @to = to
68
+ @captured = captured
69
+ @promote_to = promote_to
70
+ end
71
+
72
+ def capture(captured_square = nil, captured_piece = nil)
73
+ with(captured: CaptureData[captured_square, captured_piece])
74
+ end
75
+
76
+ def promote(piece_type)
77
+ with(promote_to: piece_type)
78
+ end
79
+
80
+ def with(piece: self.piece, from: self.from, to: self.to, captured: self.captured, promote_to: self.promote_to,
81
+ assertions: self.assertions)
82
+ self.class.new(piece, from, to, captured, promote_to,
83
+ assertions: assertions)
84
+ end
85
+ end
86
+
87
+ # Castling move.
88
+ class CastlingEvent < BaseEvent
89
+ include Wholeable[:color, :side]
90
+
91
+ SIDES = CastlingData::SIDES
92
+
93
+ def initialize(color, side, **)
94
+ super(**)
95
+ @color = color
96
+ @side = side
97
+ end
98
+
99
+ def king_from
100
+ ensure_validity
101
+ CastlingData.king_from(color, side)
102
+ end
103
+
104
+ def king_to
105
+ ensure_validity
106
+ CastlingData.king_to(color, side)
107
+ end
108
+
109
+ def rook_from
110
+ ensure_validity
111
+ CastlingData.rook_from(color, side)
112
+ end
113
+
114
+ def rook_to
115
+ ensure_validity
116
+ CastlingData.rook_to(color, side)
117
+ end
118
+
119
+ # For compatibility with the other events
120
+ def captured = nil
121
+
122
+ def with(color: self.color, side: self.side, assertions: self.assertions)
123
+ self.class.new(color, side, assertions: assertions)
124
+ end
125
+
126
+ private
127
+
128
+ def ensure_validity
129
+ return if Colors.valid?(color) && SIDES.include?(side)
130
+
131
+ raise ArgumentError, "Invalid fields for CastlingEvent: #{color}, #{side}"
132
+ end
133
+ end
134
+
135
+ # En passant move.
136
+ class EnPassantEvent < BaseEvent
137
+ include Wholeable[:color, :from, :to]
138
+
139
+ def initialize(color, from, to, **)
140
+ super(**)
141
+ @color = color
142
+ @from = from
143
+ @to = to
144
+ end
145
+
146
+ def piece
147
+ Piece[color, :pawn]
148
+ end
149
+
150
+ def captured
151
+ opponent_color = if Colors.valid?(color)
152
+ color == :white ? :black : :white
153
+ end
154
+
155
+ CaptureData[Square[to&.file, from&.rank], Piece[opponent_color, :pawn]]
156
+ end
157
+
158
+ def with(color: self.color, from: self.from, to: self.to, assertions: self.assertions)
159
+ self.class.new(color, from, to, assertions: assertions)
160
+ end
161
+ end
162
+
163
+ # Information about a captured piece. Used as a field/getter in certain events.
164
+ CaptureData = Data.define(:square, :piece) do
165
+ def initialize(square: nil, piece: nil)
166
+ super
167
+ end
168
+ end
169
+ end
170
+
171
+ MovePieceEvent = Events::MovePieceEvent
172
+ CastlingEvent = Events::CastlingEvent
173
+ EnPassantEvent = Events::EnPassantEvent
174
+ end