feen 5.0.0.beta0 → 5.0.0.beta2
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 +4 -4
- data/LICENSE.md +1 -1
- data/README.md +139 -51
- data/lib/feen/converter/from_fen.rb +170 -0
- data/lib/feen/converter/to_fen.rb +153 -0
- data/lib/feen/converter.rb +16 -0
- data/lib/feen/dumper/games_turn.rb +92 -0
- data/lib/feen/dumper/piece_placement.rb +104 -67
- data/lib/feen/dumper/pieces_in_hand.rb +44 -16
- data/lib/feen/dumper.rb +43 -30
- data/lib/feen/parser/games_turn.rb +136 -0
- data/lib/feen/parser/piece_placement.rb +204 -44
- data/lib/feen/parser/pieces_in_hand.rb +63 -22
- data/lib/feen/parser.rb +39 -27
- data/lib/feen/sanitizer.rb +119 -0
- data/lib/feen.rb +91 -46
- metadata +12 -10
- data/lib/feen/parser/board_shape.rb +0 -37
@@ -2,59 +2,219 @@
|
|
2
2
|
|
3
3
|
module Feen
|
4
4
|
module Parser
|
5
|
-
#
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
5
|
+
# Handles parsing of the piece placement section of a FEEN string
|
6
|
+
module PiecePlacement
|
7
|
+
# Error messages
|
8
|
+
ERRORS = {
|
9
|
+
invalid_type: "Piece placement must be a string, got %s",
|
10
|
+
empty_string: "Piece placement string cannot be empty",
|
11
|
+
invalid_chars: "Invalid characters in piece placement: %s",
|
12
|
+
invalid_prefix: "Expected piece identifier after '+' prefix",
|
13
|
+
invalid_piece: "Invalid piece identifier at position %d: %s",
|
14
|
+
trailing_separator: "Unexpected separator at the end of string or dimension group"
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
# Valid characters for validation
|
18
|
+
VALID_CHARS_PATTERN = %r{\A[a-zA-Z0-9/+<=>\s]+\z}
|
19
|
+
|
20
|
+
# Empty string for initialization
|
21
|
+
EMPTY_STRING = ""
|
22
|
+
|
23
|
+
# Dimension separator character
|
24
|
+
DIMENSION_SEPARATOR = "/"
|
25
|
+
|
26
|
+
# Piece promotion prefix
|
27
|
+
PREFIX_PROMOTION = "+"
|
28
|
+
|
29
|
+
# Valid piece suffixes
|
30
|
+
SUFFIX_EQUALS = "="
|
31
|
+
SUFFIX_LEFT = "<"
|
32
|
+
SUFFIX_RIGHT = ">"
|
33
|
+
VALID_SUFFIXES = [SUFFIX_EQUALS, SUFFIX_LEFT, SUFFIX_RIGHT].freeze
|
34
|
+
|
35
|
+
# Parses the piece placement section of a FEEN string
|
36
|
+
#
|
37
|
+
# @param piece_placement_str [String] FEEN piece placement string
|
38
|
+
# @return [Array] Hierarchical array structure representing the board
|
39
|
+
# @raise [ArgumentError] If the input string is invalid
|
40
|
+
def self.parse(piece_placement_str)
|
41
|
+
validate_piece_placement_string(piece_placement_str)
|
42
|
+
|
43
|
+
# Check for trailing separators that don't contribute to dimension structure
|
44
|
+
raise ArgumentError, ERRORS[:trailing_separator] if piece_placement_str.end_with?(DIMENSION_SEPARATOR)
|
45
|
+
|
46
|
+
parse_dimension_group(piece_placement_str)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Validates the piece placement string for basic syntax
|
50
|
+
#
|
51
|
+
# @param str [String] FEEN piece placement string
|
52
|
+
# @raise [ArgumentError] If the string is invalid
|
53
|
+
# @return [void]
|
54
|
+
def self.validate_piece_placement_string(str)
|
55
|
+
raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
|
56
|
+
|
57
|
+
raise ArgumentError, ERRORS[:empty_string] if str.empty?
|
58
|
+
|
59
|
+
# Check for valid characters
|
60
|
+
return if str.match?(VALID_CHARS_PATTERN)
|
61
|
+
|
62
|
+
invalid_chars = str.scan(%r{[^a-zA-Z0-9/+<=>]}).uniq.join(", ")
|
63
|
+
raise ArgumentError, format(ERRORS[:invalid_chars], invalid_chars)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Finds all separator types present in the string (e.g., /, //, ///)
|
67
|
+
#
|
68
|
+
# @param str [String] FEEN dimension group string
|
69
|
+
# @return [Array<Integer>] Sorted array of separator depths (1 for /, 2 for //, etc.)
|
70
|
+
def self.find_separator_types(str)
|
71
|
+
# Find all consecutive sequences of '/'
|
72
|
+
separators = str.scan(%r{/+})
|
73
|
+
return [] if separators.empty?
|
74
|
+
|
75
|
+
# Return a unique sorted array of separator lengths
|
76
|
+
separators.map(&:length).uniq.sort
|
77
|
+
end
|
78
|
+
|
79
|
+
# Finds the minimum dimension depth in the string
|
80
|
+
#
|
81
|
+
# @param str [String] FEEN dimension group string
|
82
|
+
# @return [Integer] Minimum dimension depth (defaults to 1)
|
83
|
+
def self.find_min_dimension_depth(str)
|
84
|
+
separator_types = find_separator_types(str)
|
85
|
+
separator_types.empty? ? 1 : separator_types.first
|
34
86
|
end
|
35
87
|
|
36
|
-
#
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
88
|
+
# Recursively parses a dimension group
|
89
|
+
#
|
90
|
+
# @param str [String] FEEN dimension group string
|
91
|
+
# @return [Array] Hierarchical array structure representing the dimension group
|
92
|
+
def self.parse_dimension_group(str)
|
93
|
+
# Check for trailing separators at each level
|
94
|
+
raise ArgumentError, ERRORS[:trailing_separator] if str.end_with?(DIMENSION_SEPARATOR)
|
95
|
+
|
96
|
+
# Find all separator types present in the string
|
97
|
+
separator_types = find_separator_types(str)
|
98
|
+
return parse_rank(str) if separator_types.empty?
|
99
|
+
|
100
|
+
# Start with the deepest separator (largest number of consecutive /)
|
101
|
+
max_depth = separator_types.last
|
102
|
+
separator = DIMENSION_SEPARATOR * max_depth
|
103
|
+
|
104
|
+
# Split the string by this separator depth
|
105
|
+
parts = split_by_separator(str, separator)
|
106
|
+
|
107
|
+
# Create the hierarchical structure
|
108
|
+
parts.map do |part|
|
109
|
+
# Check each part for trailing separators of lower depths
|
110
|
+
raise ArgumentError, ERRORS[:trailing_separator] if part.end_with?(DIMENSION_SEPARATOR)
|
111
|
+
|
112
|
+
if max_depth == 1
|
113
|
+
# If this is the lowest level separator, parse as ranks
|
114
|
+
parse_rank(part)
|
115
|
+
else
|
116
|
+
# Otherwise, continue recursively with lower level separators
|
117
|
+
parse_dimension_group(part)
|
118
|
+
end
|
119
|
+
end
|
41
120
|
end
|
42
121
|
|
43
|
-
#
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
122
|
+
# Splits a string by a given separator, preserving separators of different depths
|
123
|
+
#
|
124
|
+
# @param str [String] String to split
|
125
|
+
# @param separator [String] Separator to split by (e.g., "/", "//")
|
126
|
+
# @return [Array<String>] Array of split parts
|
127
|
+
def self.split_by_separator(str, separator)
|
128
|
+
return [str] unless str.include?(separator)
|
129
|
+
|
130
|
+
parts = []
|
131
|
+
current_part = ""
|
132
|
+
i = 0
|
49
133
|
|
50
|
-
|
134
|
+
while i < str.length
|
135
|
+
# Si nous trouvons le début d'un séparateur potentiel
|
136
|
+
if str[i] == DIMENSION_SEPARATOR[0]
|
137
|
+
# Vérifier si c'est notre séparateur exact
|
138
|
+
if i <= str.length - separator.length && str[i, separator.length] == separator
|
139
|
+
# C'est notre séparateur, ajouter la partie actuelle à la liste
|
140
|
+
parts << current_part unless current_part.empty?
|
141
|
+
current_part = ""
|
142
|
+
i += separator.length
|
143
|
+
else
|
144
|
+
# Ce n'est pas notre séparateur exact, compter combien de '/' consécutifs
|
145
|
+
start = i
|
146
|
+
i += 1 while i < str.length && str[i] == DIMENSION_SEPARATOR[0]
|
147
|
+
# Ajouter ces '/' à la partie actuelle
|
148
|
+
current_part += str[start...i]
|
149
|
+
end
|
150
|
+
else
|
151
|
+
# Caractère normal, l'ajouter à la partie actuelle
|
152
|
+
current_part += str[i]
|
153
|
+
i += 1
|
51
154
|
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Ajouter la dernière partie si elle n'est pas vide
|
158
|
+
parts << current_part unless current_part.empty?
|
159
|
+
|
160
|
+
parts
|
52
161
|
end
|
53
162
|
|
54
|
-
|
163
|
+
# Parses a rank (sequence of cells)
|
164
|
+
#
|
165
|
+
# @param str [String] FEEN rank string
|
166
|
+
# @return [Array] Array of cells (nil for empty, hash for piece)
|
167
|
+
def self.parse_rank(str)
|
168
|
+
return [] if str.nil? || str.empty?
|
169
|
+
|
170
|
+
cells = []
|
171
|
+
i = 0
|
172
|
+
|
173
|
+
while i < str.length
|
174
|
+
char = str[i]
|
175
|
+
|
176
|
+
if char.match?(/[1-9]/)
|
177
|
+
# Handle empty cells (digits represent consecutive empty squares)
|
178
|
+
empty_count = EMPTY_STRING
|
179
|
+
while i < str.length && str[i].match?(/[0-9]/)
|
180
|
+
empty_count += str[i]
|
181
|
+
i += 1
|
182
|
+
end
|
183
|
+
|
184
|
+
empty_count.to_i.times { cells << nil }
|
185
|
+
else
|
186
|
+
# Handle pieces
|
187
|
+
piece = {}
|
188
|
+
|
189
|
+
# Check for prefix
|
190
|
+
if char == PREFIX_PROMOTION
|
191
|
+
piece[:prefix] = PREFIX_PROMOTION
|
192
|
+
i += 1
|
193
|
+
|
194
|
+
# Ensure there's a piece identifier after the prefix
|
195
|
+
raise ArgumentError, ERRORS[:invalid_prefix] if i >= str.length || !str[i].match?(/[a-zA-Z]/)
|
196
|
+
|
197
|
+
char = str[i]
|
198
|
+
end
|
199
|
+
|
200
|
+
# Get the piece identifier
|
201
|
+
raise ArgumentError, format(ERRORS[:invalid_piece], i, char) unless char.match?(/[a-zA-Z]/)
|
202
|
+
|
203
|
+
piece[:id] = char
|
204
|
+
i += 1
|
205
|
+
|
206
|
+
# Check for suffix
|
207
|
+
if i < str.length && VALID_SUFFIXES.include?(str[i])
|
208
|
+
piece[:suffix] = str[i]
|
209
|
+
i += 1
|
210
|
+
end
|
211
|
+
|
212
|
+
cells << piece
|
213
|
+
|
214
|
+
end
|
215
|
+
end
|
55
216
|
|
56
|
-
|
57
|
-
string.match?(/[0-9]+/) ? ::Array.new(Integer(string)) : string
|
217
|
+
cells
|
58
218
|
end
|
59
219
|
end
|
60
220
|
end
|
@@ -2,32 +2,73 @@
|
|
2
2
|
|
3
3
|
module Feen
|
4
4
|
module Parser
|
5
|
-
#
|
5
|
+
# Handles parsing of the pieces in hand section of a FEEN string
|
6
6
|
module PiecesInHand
|
7
|
-
|
8
|
-
|
9
|
-
|
7
|
+
NO_PIECES = "-"
|
8
|
+
ERRORS = {
|
9
|
+
invalid_type: "Pieces in hand must be a string, got %s",
|
10
|
+
empty_string: "Pieces in hand string cannot be empty",
|
11
|
+
invalid_chars: "Invalid characters in pieces in hand: %s",
|
12
|
+
invalid_identifier: "Invalid piece identifier at position %d"
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
# Parses the pieces in hand section of a FEEN string
|
10
16
|
#
|
11
|
-
# @
|
12
|
-
#
|
13
|
-
#
|
17
|
+
# @param pieces_in_hand_str [String] FEEN pieces in hand string
|
18
|
+
# @return [Array<Hash>] Array of pieces in hand
|
19
|
+
# @raise [ArgumentError] If the input string is invalid
|
20
|
+
def self.parse(pieces_in_hand_str)
|
21
|
+
validate_pieces_in_hand_string(pieces_in_hand_str)
|
22
|
+
|
23
|
+
# Handle the special case of no pieces in hand
|
24
|
+
return [] if pieces_in_hand_str == NO_PIECES
|
25
|
+
|
26
|
+
pieces = []
|
27
|
+
i = 0
|
28
|
+
|
29
|
+
while i < pieces_in_hand_str.length
|
30
|
+
# Vérifier que le caractère est une lettre
|
31
|
+
raise ArgumentError, format(ERRORS[:invalid_identifier], i) unless pieces_in_hand_str[i].match?(/[a-zA-Z]/)
|
32
|
+
|
33
|
+
pieces << { id: pieces_in_hand_str[i] }
|
34
|
+
i += 1
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
# Vérifier que les pièces sont triées par ordre lexicographique
|
39
|
+
raise ArgumentError, "Pieces in hand must be in ASCII lexicographic order" unless pieces_sorted?(pieces)
|
40
|
+
|
41
|
+
pieces
|
42
|
+
end
|
43
|
+
|
44
|
+
# Validates the pieces in hand string for syntax
|
14
45
|
#
|
15
|
-
# @
|
16
|
-
#
|
17
|
-
#
|
46
|
+
# @param str [String] FEEN pieces in hand string
|
47
|
+
# @raise [ArgumentError] If the string is invalid
|
48
|
+
# @return [void]
|
49
|
+
def self.validate_pieces_in_hand_string(str)
|
50
|
+
raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
|
51
|
+
|
52
|
+
raise ArgumentError, ERRORS[:empty_string] if str.empty?
|
53
|
+
|
54
|
+
# Check for the special case of no pieces in hand
|
55
|
+
return if str == NO_PIECES
|
56
|
+
|
57
|
+
# Check for valid characters (only letters)
|
58
|
+
valid_chars = /\A[a-zA-Z]+\z/
|
59
|
+
return if str.match?(valid_chars)
|
60
|
+
|
61
|
+
invalid_chars = str.scan(/[^a-zA-Z]/).uniq.join(", ")
|
62
|
+
raise ArgumentError, format(ERRORS[:invalid_chars], invalid_chars)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Checks if pieces are sorted in ASCII lexicographic order
|
18
66
|
#
|
19
|
-
# @
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
if piece.include?("*")
|
25
|
-
letter, count = piece.split("*")
|
26
|
-
[letter] * count.to_i
|
27
|
-
else
|
28
|
-
piece
|
29
|
-
end
|
30
|
-
end.sort
|
67
|
+
# @param pieces [Array<Hash>] Array of piece hashes
|
68
|
+
# @return [Boolean] True if pieces are sorted
|
69
|
+
def self.pieces_sorted?(pieces)
|
70
|
+
piece_ids = pieces.map { |piece| piece[:id] }
|
71
|
+
piece_ids == piece_ids.sort
|
31
72
|
end
|
32
73
|
end
|
33
74
|
end
|
data/lib/feen/parser.rb
CHANGED
@@ -1,40 +1,52 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative File.join("parser", "
|
4
|
-
require_relative File.join("parser", "pieces_in_hand")
|
3
|
+
require_relative File.join("parser", "games_turn")
|
5
4
|
require_relative File.join("parser", "piece_placement")
|
5
|
+
require_relative File.join("parser", "pieces_in_hand")
|
6
6
|
|
7
7
|
module Feen
|
8
|
-
#
|
8
|
+
# Module responsible for parsing FEEN notation strings into internal data structures
|
9
9
|
module Parser
|
10
|
-
#
|
11
|
-
#
|
12
|
-
# @param feen [String] The FEEN string representing a position.
|
13
|
-
#
|
14
|
-
# @example Parse a classic Tsume Shogi problem
|
15
|
-
# call("3,s,k,s,3/9/4,+P,4/9/7,+B,1/9/9/9/9 s S,b,g*4,n*4,p*17,r*2,s")
|
16
|
-
# # => {
|
17
|
-
# # "side_to_move": "s",
|
18
|
-
# # "pieces_in_hand": ["S", "b", "g", "g", "g", "g", "n", "n", "n", "n", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "p", "r", "r", "s"],
|
19
|
-
# # "board_shape": [9, 9],
|
20
|
-
# # "piece_placement": {
|
21
|
-
# # 3 => "s",
|
22
|
-
# # 4 => "k",
|
23
|
-
# # 5 => "s",
|
24
|
-
# # 22 => "+P",
|
25
|
-
# # 43 => "+B"
|
26
|
-
# # }
|
10
|
+
# Parses a complete FEEN string into a structured representation
|
27
11
|
#
|
28
|
-
# @
|
29
|
-
|
30
|
-
|
12
|
+
# @param feen_string [String] Complete FEEN notation string
|
13
|
+
# @return [Hash] Hash containing the parsed position data
|
14
|
+
# @raise [ArgumentError] If the FEEN string is invalid
|
15
|
+
def self.parse(feen_string)
|
16
|
+
validate_feen_string(feen_string)
|
17
|
+
|
18
|
+
# Split the FEEN string into its three fields
|
19
|
+
fields = feen_string.strip.split(/\s+/)
|
20
|
+
|
21
|
+
raise ArgumentError, "Invalid FEEN format: expected 3 fields, got #{fields.size}" unless fields.size == 3
|
22
|
+
|
23
|
+
# Parse each field using the appropriate submodule
|
24
|
+
piece_placement = PiecePlacement.parse(fields[0])
|
25
|
+
games_turn = GamesTurn.parse(fields[1])
|
26
|
+
pieces_in_hand = PiecesInHand.parse(fields[2])
|
31
27
|
|
28
|
+
# Return a structured representation of the position
|
32
29
|
{
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
side_to_move:
|
30
|
+
piece_placement: piece_placement,
|
31
|
+
games_turn: games_turn,
|
32
|
+
pieces_in_hand: pieces_in_hand
|
37
33
|
}
|
38
34
|
end
|
35
|
+
|
36
|
+
# Validates the FEEN string for basic format
|
37
|
+
#
|
38
|
+
# @param feen_string [String] FEEN string to validate
|
39
|
+
# @raise [ArgumentError] If the FEEN string is fundamentally invalid
|
40
|
+
# @return [void]
|
41
|
+
def self.validate_feen_string(feen_string)
|
42
|
+
raise ArgumentError, "FEEN must be a string, got #{feen_string.class}" unless feen_string.is_a?(String)
|
43
|
+
|
44
|
+
raise ArgumentError, "FEEN string cannot be empty" if feen_string.empty?
|
45
|
+
|
46
|
+
# Check for at least two spaces (three fields)
|
47
|
+
return unless feen_string.count(" ") < 2
|
48
|
+
|
49
|
+
raise ArgumentError, "Invalid FEEN format: must contain at least two spaces separating three fields"
|
50
|
+
end
|
39
51
|
end
|
40
52
|
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
# Provides methods for sanitizing and validating chess-related notation strings
|
5
|
+
module Sanitizer
|
6
|
+
# Cleans a FEN (Forsyth-Edwards Notation) string by removing invalid castling rights
|
7
|
+
# and en passant targets based on the current position.
|
8
|
+
#
|
9
|
+
# The method performs the following validations:
|
10
|
+
# - Verifies that kings and rooks are in correct positions for castling rights
|
11
|
+
# - Verifies that en passant captures are actually possible
|
12
|
+
#
|
13
|
+
# @param fen_string [String] The FEN string to clean
|
14
|
+
# @return [String] A sanitized FEN string with invalid castling rights and en passant targets removed
|
15
|
+
# @raise [ArgumentError] If the FEN string is malformed (less than 4 parts)
|
16
|
+
#
|
17
|
+
# @example Clean a valid FEN string (unchanged)
|
18
|
+
# fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
19
|
+
# Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
20
|
+
#
|
21
|
+
# @example Remove invalid castling rights when king has moved
|
22
|
+
# fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQ1BNR w KQkq - 0 1"
|
23
|
+
# Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQ1BNR w kq - 0 1"
|
24
|
+
#
|
25
|
+
# @example Remove invalid castling rights when rook has moved
|
26
|
+
# fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/1NBQKBNR w KQkq - 0 1"
|
27
|
+
# Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/1NBQKBNR w Kkq - 0 1"
|
28
|
+
#
|
29
|
+
# @example Remove invalid en passant target when no capturing pawn exists
|
30
|
+
# fen = "rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPPPPPP/RNBQKBNR w KQkq e6 0 2"
|
31
|
+
# Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppp1ppp/8/4p3/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 2"
|
32
|
+
#
|
33
|
+
# @example Keep valid en passant target when capturing is possible
|
34
|
+
# fen = "rnbqkbnr/pppp1ppp/8/3Pp3/8/8/PPP1PPPP/RNBQKBNR w KQkq e6 0 2"
|
35
|
+
# Feen::Sanitizer.clean_fen(fen) # => "rnbqkbnr/pppp1ppp/8/3Pp3/8/8/PPP1PPPP/RNBQKBNR w KQkq e6 0 2"
|
36
|
+
def self.clean_fen(fen_string)
|
37
|
+
parts = fen_string.strip.split
|
38
|
+
return fen_string unless parts.size >= 4
|
39
|
+
|
40
|
+
board, active_color, castling, en_passant, *rest = parts
|
41
|
+
|
42
|
+
# Parse board into a 2D array for easier access
|
43
|
+
board_matrix = []
|
44
|
+
board.split("/").each do |row|
|
45
|
+
current_row = []
|
46
|
+
row.each_char do |c|
|
47
|
+
if /[1-8]/.match?(c)
|
48
|
+
c.to_i.times { current_row << nil }
|
49
|
+
else
|
50
|
+
current_row << c
|
51
|
+
end
|
52
|
+
end
|
53
|
+
board_matrix << current_row
|
54
|
+
end
|
55
|
+
|
56
|
+
# Clean castling rights
|
57
|
+
new_castling = castling.dup
|
58
|
+
new_castling = clean_castling_rights(new_castling, board_matrix)
|
59
|
+
|
60
|
+
# Clean en passant target
|
61
|
+
new_en_passant = clean_en_passant_target(en_passant, board_matrix, active_color)
|
62
|
+
|
63
|
+
([board, active_color, new_castling, new_en_passant] + rest).join(" ")
|
64
|
+
end
|
65
|
+
|
66
|
+
# Validates and cleans castling rights based on the position of kings and rooks
|
67
|
+
#
|
68
|
+
# @param castling [String] The castling rights string from FEN
|
69
|
+
# @param board [Array<Array<String, nil>>] 2D array representing the board
|
70
|
+
# @return [String] Cleaned castling rights or "-" if none are valid
|
71
|
+
# @api private
|
72
|
+
private_class_method def self.clean_castling_rights(castling, board)
|
73
|
+
return "-" if castling == "-"
|
74
|
+
|
75
|
+
new_castling = castling.dup
|
76
|
+
|
77
|
+
# White castling rights
|
78
|
+
new_castling.gsub!(/[KQ]/, "") unless board[7][4] == "K"
|
79
|
+
new_castling.delete!("K") unless board[7][7] == "R"
|
80
|
+
new_castling.delete!("Q") unless board[7][0] == "R"
|
81
|
+
|
82
|
+
# Black castling rights
|
83
|
+
new_castling.gsub!(/[kq]/, "") unless board[0][4] == "k"
|
84
|
+
new_castling.delete!("k") unless board[0][7] == "r"
|
85
|
+
new_castling.delete!("q") unless board[0][0] == "r"
|
86
|
+
|
87
|
+
new_castling.empty? ? "-" : new_castling
|
88
|
+
end
|
89
|
+
|
90
|
+
# Validates and cleans en passant target based on the position of pawns
|
91
|
+
#
|
92
|
+
# @param en_passant [String] The en passant target square from FEN
|
93
|
+
# @param board [Array<Array<String, nil>>] 2D array representing the board
|
94
|
+
# @param active_color [String] The active color ("w" or "b")
|
95
|
+
# @return [String] Cleaned en passant target or "-" if invalid
|
96
|
+
# @api private
|
97
|
+
private_class_method def self.clean_en_passant_target(en_passant, board, active_color)
|
98
|
+
return "-" if en_passant == "-"
|
99
|
+
|
100
|
+
file = en_passant[0].ord - "a".ord
|
101
|
+
rank = en_passant[1].to_i
|
102
|
+
|
103
|
+
# Validate en passant square coordinates
|
104
|
+
return "-" unless file.between?(0, 7) && [3, 6].include?(rank)
|
105
|
+
|
106
|
+
# For white's move (after black pawn double advance)
|
107
|
+
if active_color == "w" && rank == 6
|
108
|
+
# Check for white pawns on the 5th rank (index 3) that can capture
|
109
|
+
return en_passant if [file - 1, file + 1].any? { |f| f.between?(0, 7) && board[3][f] == "P" }
|
110
|
+
# For black's move (after white pawn double advance)
|
111
|
+
elsif active_color == "b" && rank == 3
|
112
|
+
# Check for black pawns on the 4th rank (index 4) that can capture
|
113
|
+
return en_passant if [file - 1, file + 1].any? { |f| f.between?(0, 7) && board[4][f] == "p" }
|
114
|
+
end
|
115
|
+
|
116
|
+
"-" # Invalid en passant square
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|