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