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
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
|